From 338251e60c1361ec337e203deae2f6cc21ad0863 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sat, 23 May 2026 22:26:01 -0500 Subject: [PATCH 01/40] refactor: remove deprecated services and logic (#2722) --- backend/api/api.go | 5 +- backend/api/handlers/container_registries.go | 2 +- backend/api/handlers/environments.go | 35 +- backend/api/handlers/events.go | 4 +- backend/api/handlers/git_repositories.go | 2 +- backend/api/handlers/gitops_syncs.go | 2 +- backend/api/handlers/helpers.go | 15 +- backend/api/handlers/images.go | 2 +- backend/api/handlers/notifications.go | 148 +----- backend/api/handlers/notifications_test.go | 2 +- backend/api/handlers/settings.go | 2 +- backend/api/handlers/templates.go | 2 +- backend/api/handlers/users.go | 2 +- backend/api/handlers/vulnerabilities.go | 17 +- backend/internal/bootstrap/bootstrap.go | 25 +- backend/internal/bootstrap/bootstrap_test.go | 23 - .../internal/bootstrap/router_bootstrap.go | 1 - .../internal/bootstrap/services_bootstrap.go | 2 - backend/internal/common/errors.go | 30 -- backend/internal/configschema/schema.go | 33 -- backend/internal/configschema/schema_test.go | 13 - backend/internal/models/notification.go | 14 - backend/internal/models/settings.go | 120 ++--- backend/internal/models/settings_test.go | 37 -- backend/internal/services/apprise_service.go | 255 --------- .../internal/services/environment_service.go | 59 -- .../services/environment_service_test.go | 8 - .../internal/services/gitops_sync_service.go | 37 -- .../internal/services/notification_service.go | 481 ++++++++--------- .../services/notification_service_test.go | 259 +-------- backend/internal/services/settings_service.go | 367 ++----------- .../services/settings_service_test.go | 290 ---------- backend/pkg/dockerutil/jsonstream.go | 10 - backend/pkg/dockerutil/jsonstream_test.go | 7 - .../pkg/libarcane/edge/client_grpc_test.go | 94 +--- backend/pkg/libarcane/edge/commands.go | 3 - backend/pkg/libarcane/edge/registry.go | 3 +- backend/pkg/libarcane/edge/tls.go | 54 +- backend/pkg/libarcane/edge/tls_test.go | 46 -- backend/pkg/libarcane/edge/transport.go | 16 +- backend/pkg/libarcane/edge/transport_test.go | 10 +- backend/pkg/libarcane/internal_resource.go | 8 +- backend/pkg/libarcane/startup/startup.go | 353 ------------ backend/pkg/libarcane/startup/startup_test.go | 503 ++---------------- backend/pkg/scheduler/auto_update_job.go | 17 - .../pkg/scheduler/environment_health_job.go | 14 - backend/pkg/scheduler/event_cleanup_job.go | 14 - backend/pkg/scheduler/gitops_sync_job.go | 14 - backend/pkg/scheduler/gitops_sync_job_test.go | 10 - backend/pkg/scheduler/image_polling_job.go | 14 - backend/pkg/scheduler/scheduled_prune_job.go | 17 - .../pkg/scheduler/vulnerability_scan_job.go | 17 - .../postgres/053_drop_deprecated_v2.down.sql | 10 + .../postgres/053_drop_deprecated_v2.up.sql | 12 + .../sqlite/053_drop_deprecated_v2.down.sql | 10 + .../sqlite/053_drop_deprecated_v2.up.sql | 12 + cli/internal/config/config.go | 52 -- cli/internal/config/config_test.go | 50 +- .../projects_json_compat_test.go | 112 ---- cli/internal/types/config.go | 28 +- cli/internal/types/endpoints.go | 12 - cli/pkg/admin/notifications/cmd.go | 85 --- cli/pkg/config/cmd.go | 6 +- frontend/messages/en.json | 13 - .../src/lib/components/action-buttons.svelte | 19 +- .../src/lib/services/notification-service.ts | 19 +- frontend/src/lib/types/container.type.ts | 5 - .../src/lib/types/notification-providers.ts | 27 +- frontend/src/lib/types/notification.type.ts | 8 - .../components/ContainerHealthcheck.svelte | 9 +- .../routes/(app)/images/builds/+page.svelte | 13 +- .../(app)/settings/notifications/+page.svelte | 306 ++++------- .../providers/AppriseProviderForm.svelte | 62 --- .../settings/notifications/providers/index.ts | 1 - tests/spec/cli.spec.ts | 7 - tests/spec/settings-notifications.spec.ts | 14 - types/environment/environment.go | 10 - types/notification/notification.go | 49 -- types/settings/settings.go | 47 -- 79 files changed, 533 insertions(+), 3983 deletions(-) delete mode 100644 backend/internal/services/apprise_service.go create mode 100644 backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql create mode 100644 backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql create mode 100644 backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql create mode 100644 backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql delete mode 100644 cli/internal/integrationtest/projects_json_compat_test.go delete mode 100644 frontend/src/routes/(app)/settings/notifications/providers/AppriseProviderForm.svelte diff --git a/backend/api/api.go b/backend/api/api.go index 0d3d2143bb..a236f7c791 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -164,7 +164,6 @@ type Services struct { Port *services.PortService Swarm *services.SwarmService Notification *services.NotificationService - Apprise *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr Updater *services.UpdaterService CustomizeSearch *services.CustomizeSearchService System *services.SystemService @@ -337,7 +336,6 @@ func registerHandlers(api huma.API, svc *Services) { var portSvc *services.PortService var swarmSvc *services.SwarmService var notificationSvc *services.NotificationService - var appriseSvc *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr var updaterSvc *services.UpdaterService var customizeSearchSvc *services.CustomizeSearchService var systemSvc *services.SystemService @@ -376,7 +374,6 @@ func registerHandlers(api huma.API, svc *Services) { portSvc = svc.Port swarmSvc = svc.Swarm notificationSvc = svc.Notification - appriseSvc = svc.Apprise updaterSvc = svc.Updater customizeSearchSvc = svc.CustomizeSearch systemSvc = svc.System @@ -411,7 +408,7 @@ func registerHandlers(api huma.API, svc *Services) { handlers.RegisterPorts(api, portSvc) handlers.RegisterNetworks(api, networkSvc, dockerSvc) handlers.RegisterSwarm(api, swarmSvc, environmentSvc, eventSvc, cfg) - handlers.RegisterNotifications(api, notificationSvc, appriseSvc, cfg) + handlers.RegisterNotifications(api, notificationSvc, cfg) handlers.RegisterUpdater(api, updaterSvc) handlers.RegisterCustomize(api, customizeSearchSvc) handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg) diff --git a/backend/api/handlers/container_registries.go b/backend/api/handlers/container_registries.go index 010104329b..f8ebf01b67 100644 --- a/backend/api/handlers/container_registries.go +++ b/backend/api/handlers/container_registries.go @@ -229,7 +229,7 @@ func (h *ContainerRegistryHandler) ListRegistries(ctx context.Context, input *Li return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) registries, paginationResp, err := h.registryService.GetRegistriesPaginated(ctx, params) if err != nil { diff --git a/backend/api/handlers/environments.go b/backend/api/handlers/environments.go index af747fa6a0..dc137827a4 100644 --- a/backend/api/handlers/environments.go +++ b/backend/api/handlers/environments.go @@ -550,15 +550,7 @@ func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, en } func (h *EnvironmentHandler) createEnvironmentLegacy(ctx context.Context, env *models.Environment, user *models.User, body environment.Create) (*CreateEnvironmentOutput, error) { - // Legacy pairing flows - if (body.AccessToken == nil || *body.AccessToken == "") && body.BootstrapToken != nil && *body.BootstrapToken != "" { - token, err := h.environmentService.PairAgentWithBootstrap(ctx, body.ApiUrl, *body.BootstrapToken) - if err != nil { - slog.ErrorContext(ctx, "Failed to pair with agent", "apiUrl", body.ApiUrl, "error", err.Error()) - return nil, huma.Error502BadGateway((&common.AgentPairingError{Err: err}).Error()) - } - env.AccessToken = &token - } else if body.AccessToken != nil && *body.AccessToken != "" { + if body.AccessToken != nil && *body.AccessToken != "" { env.AccessToken = body.AccessToken } @@ -891,32 +883,17 @@ func (h *EnvironmentHandler) buildUpdateMap(req *environment.Update, isLocalEnv } func (h *EnvironmentHandler) handleEnvironmentPairing(ctx context.Context, environmentID string, req *environment.Update, updates map[string]any, isLocalEnv bool) (bool, error) { - pairingSucceeded := false - + _ = ctx + _ = environmentID if isLocalEnv { - return pairingSucceeded, nil + return false, nil } - if req.AccessToken == nil && req.BootstrapToken != nil && *req.BootstrapToken != "" { - current, err := h.environmentService.GetEnvironmentByID(ctx, environmentID) - if err != nil || current == nil { - return false, huma.Error404NotFound("Environment not found") - } - - apiUrl := current.ApiUrl - if req.ApiUrl != nil && *req.ApiUrl != "" { - apiUrl = *req.ApiUrl - } - - if _, err := h.environmentService.PairAndPersistAgentToken(ctx, environmentID, apiUrl, *req.BootstrapToken); err != nil { - return false, huma.Error502BadGateway("Agent pairing failed: " + err.Error()) - } - pairingSucceeded = true - } else if req.AccessToken != nil { + if req.AccessToken != nil { updates["access_token"] = *req.AccessToken } - return pairingSucceeded, nil + return false, nil } func (h *EnvironmentHandler) triggerPostUpdateTasks(ctx context.Context, environmentID string, updated *models.Environment, pairingSucceeded bool, req *environment.Update) { //nolint:contextcheck // intentionally spawns background tasks diff --git a/backend/api/handlers/events.go b/backend/api/handlers/events.go index ae15b22eed..5d0ff6658a 100644 --- a/backend/api/handlers/events.go +++ b/backend/api/handlers/events.go @@ -156,7 +156,7 @@ func (h *EventHandler) ListEvents(ctx context.Context, input *ListEventsInput) ( return nil, err } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Severity != "" { params.Filters["severity"] = input.Severity @@ -199,7 +199,7 @@ func (h *EventHandler) GetEventsByEnvironment(ctx context.Context, input *GetEve return nil, huma.Error400BadRequest((&common.EnvironmentIDRequiredError{}).Error()) } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Severity != "" { params.Filters["severity"] = input.Severity diff --git a/backend/api/handlers/git_repositories.go b/backend/api/handlers/git_repositories.go index 8de9561fb1..969b5aa2b4 100644 --- a/backend/api/handlers/git_repositories.go +++ b/backend/api/handlers/git_repositories.go @@ -250,7 +250,7 @@ func (h *GitRepositoryHandler) ListRepositories(ctx context.Context, input *List return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) repositories, paginationResp, err := h.repoService.GetRepositoriesPaginated(ctx, params) if err != nil { diff --git a/backend/api/handlers/gitops_syncs.go b/backend/api/handlers/gitops_syncs.go index 7e9d0041b3..5a4e9b1509 100644 --- a/backend/api/handlers/gitops_syncs.go +++ b/backend/api/handlers/gitops_syncs.go @@ -258,7 +258,7 @@ func (h *GitOpsSyncHandler) ListSyncs(ctx context.Context, input *ListGitOpsSync return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) syncs, paginationResp, counts, err := h.syncService.GetSyncsPaginated(ctx, input.EnvironmentID, params) if err != nil { diff --git a/backend/api/handlers/helpers.go b/backend/api/handlers/helpers.go index e006f68a98..9ba9036365 100644 --- a/backend/api/handlers/helpers.go +++ b/backend/api/handlers/helpers.go @@ -21,22 +21,14 @@ func checkAdminInternal(ctx context.Context) error { } // buildPaginationParamsInternal converts query parameters to pagination.QueryParams. -// It supports both the legacy nested style (page/limit) and the standard style (start/limit). // A limit of -1 means "show all items" (no pagination). -func buildPaginationParamsInternal(page, start, limit int, sortCol, sortDir, search string) pagination.QueryParams { +func buildPaginationParamsInternal(start, limit int, sortCol, sortDir, search string) pagination.QueryParams { // limit = -1 means "show all", preserve it; zero or other negative values default to 20 if limit < -1 { limit = 20 } - finalStart := start - if page > 1 && start == 0 && limit > 0 { - // Convert page-based to offset-based if page is provided and start is 0 - // Skip this conversion when limit is -1 (show all) - finalStart = (page - 1) * limit - } - - params := pagination.QueryParams{ + return pagination.QueryParams{ SearchQuery: pagination.SearchQuery{ Search: search, }, @@ -45,12 +37,11 @@ func buildPaginationParamsInternal(page, start, limit int, sortCol, sortDir, sea Order: pagination.SortOrder(sortDir), }, PaginationParams: pagination.PaginationParams{ - Start: finalStart, + Start: start, Limit: limit, }, Filters: make(map[string]string), } - return params } func proxyRemoteJSONInternal[T any]( diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index 00207682e2..6dbb397d5e 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -498,7 +498,7 @@ func (h *ImageHandler) ListImageBuilds(ctx context.Context, input *ListImageBuil return nil, huma.Error400BadRequest((&common.EnvironmentIDRequiredError{}).Error()) } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Status != "" { params.Filters["status"] = input.Status } diff --git a/backend/api/handlers/notifications.go b/backend/api/handlers/notifications.go index 340b4c133c..9efe5b09dd 100644 --- a/backend/api/handlers/notifications.go +++ b/backend/api/handlers/notifications.go @@ -18,7 +18,6 @@ import ( type NotificationHandler struct { notificationService *services.NotificationService - appriseService *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr config *config.Config } @@ -67,32 +66,6 @@ type TestNotificationOutput struct { Body base.ApiResponse[base.MessageResponse] } -type GetAppriseSettingsInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` -} - -type GetAppriseSettingsOutput struct { - Body notification.AppriseResponse -} - -type CreateOrUpdateAppriseSettingsInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` - Body notification.AppriseUpdate -} - -type CreateOrUpdateAppriseSettingsOutput struct { - Body notification.AppriseResponse -} - -type TestAppriseNotificationInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` - Type string `query:"type" default:"simple"` -} - -type TestAppriseNotificationOutput struct { - Body base.ApiResponse[base.MessageResponse] -} - type DispatchNotificationInput struct { APIKey string `header:"X-API-Key" doc:"Remote environment access token"` Body notification.DispatchRequest @@ -125,12 +98,9 @@ func isSupportedNotificationTestType(testType string) bool { } // RegisterNotifications registers notification endpoints. -// -//nolint:staticcheck // AppriseService still functional, deprecated in favor of Shoutrrr -func RegisterNotifications(api huma.API, notificationSvc *services.NotificationService, appriseSvc *services.AppriseService, cfg *config.Config) { +func RegisterNotifications(api huma.API, notificationSvc *services.NotificationService, cfg *config.Config) { h := &NotificationHandler{ notificationService: notificationSvc, - appriseService: appriseSvc, config: cfg, } @@ -184,36 +154,6 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Middlewares: humamw.RequireAdmin(api), }, h.TestNotification) - huma.Register(api, huma.Operation{ - OperationID: "get-apprise-settings", - Method: http.MethodGet, - Path: "/environments/{id}/notifications/apprise", - Summary: "Get Apprise settings", - Tags: []string{"Notifications"}, - Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), - }, h.GetAppriseSettings) - - huma.Register(api, huma.Operation{ - OperationID: "create-or-update-apprise-settings", - Method: http.MethodPost, - Path: "/environments/{id}/notifications/apprise", - Summary: "Create or update Apprise settings", - Tags: []string{"Notifications"}, - Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), - }, h.CreateOrUpdateAppriseSettings) - - huma.Register(api, huma.Operation{ - OperationID: "test-apprise-notification", - Method: http.MethodPost, - Path: "/environments/{id}/notifications/apprise/test", - Summary: "Test Apprise notification", - Tags: []string{"Notifications"}, - Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), - }, h.TestAppriseNotification) - huma.Register(api, huma.Operation{ OperationID: "dispatch-notification", Method: http.MethodPost, @@ -358,92 +298,6 @@ func (h *NotificationHandler) TestNotification(ctx context.Context, input *TestN }, nil } -func (h *NotificationHandler) GetAppriseSettings(ctx context.Context, input *GetAppriseSettingsInput) (*GetAppriseSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.rejectIfAgentModeInternal(); err != nil { - return nil, err - } - settings, err := h.appriseService.GetSettings(ctx) - if err != nil { - return nil, huma.Error500InternalServerError("Failed to retrieve Apprise settings", err) - } - if settings == nil { - return nil, huma.Error404NotFound((&common.AppriseSettingsNotFoundError{}).Error()) - } - - response := notification.AppriseResponse{ - ID: settings.ID, - APIURL: settings.APIURL, - Enabled: settings.Enabled, - ImageUpdateTag: settings.ImageUpdateTag, - ContainerUpdateTag: settings.ContainerUpdateTag, - } - - return &GetAppriseSettingsOutput{Body: response}, nil -} - -func (h *NotificationHandler) CreateOrUpdateAppriseSettings(ctx context.Context, input *CreateOrUpdateAppriseSettingsInput) (*CreateOrUpdateAppriseSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.rejectIfAgentModeInternal(); err != nil { - return nil, err - } - if input.Body.Enabled && input.Body.APIURL == "" { - return nil, huma.Error400BadRequest("API URL is required when Apprise is enabled") - } - - settings, err := h.appriseService.CreateOrUpdateSettings( - ctx, - input.Body.APIURL, - input.Body.Enabled, - input.Body.ImageUpdateTag, - input.Body.ContainerUpdateTag, - ) - if err != nil { - return nil, huma.Error500InternalServerError((&common.AppriseSettingsUpdateError{Err: err}).Error()) - } - - response := notification.AppriseResponse{ - ID: settings.ID, - APIURL: settings.APIURL, - Enabled: settings.Enabled, - ImageUpdateTag: settings.ImageUpdateTag, - ContainerUpdateTag: settings.ContainerUpdateTag, - } - - return &CreateOrUpdateAppriseSettingsOutput{Body: response}, nil -} - -func (h *NotificationHandler) TestAppriseNotification(ctx context.Context, input *TestAppriseNotificationInput) (*TestAppriseNotificationOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.rejectIfAgentModeInternal(); err != nil { - return nil, err - } - testType := normalizeNotificationTestType(input.Type) - if !isSupportedNotificationTestType(testType) { - return nil, huma.Error400BadRequest("invalid notification test type") - } - target, err := h.notificationService.ResolveNotificationTarget(ctx, input.EnvironmentID) - if err != nil { - return nil, huma.Error500InternalServerError((&common.AppriseTestError{Err: err}).Error()) - } - if err := h.appriseService.TestNotification(ctx, target.EnvironmentName, testType); err != nil { - return nil, huma.Error500InternalServerError((&common.AppriseTestError{Err: err}).Error()) - } - - return &TestAppriseNotificationOutput{ - Body: base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{Message: "Test notification sent successfully"}, - }, - }, nil -} - func (h *NotificationHandler) DispatchNotification(ctx context.Context, input *DispatchNotificationInput) (*DispatchNotificationOutput, error) { if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err diff --git a/backend/api/handlers/notifications_test.go b/backend/api/handlers/notifications_test.go index d4c13fdf9b..5452a7288e 100644 --- a/backend/api/handlers/notifications_test.go +++ b/backend/api/handlers/notifications_test.go @@ -23,7 +23,7 @@ func setupNotificationHandlerTestService(t *testing.T) (*database.DB, *services. db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{}, &models.AppriseSettings{})) + require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{})) databaseDB := &database.DB{DB: db} envSvc := services.NewEnvironmentService(databaseDB, nil, nil, nil, nil, nil) diff --git a/backend/api/handlers/settings.go b/backend/api/handlers/settings.go index 804792562f..47e9c7dcc7 100644 --- a/backend/api/handlers/settings.go +++ b/backend/api/handlers/settings.go @@ -399,7 +399,7 @@ func (h *SettingsHandler) updateSettingsForLocalEnvironment(ctx context.Context, func hasAuthSettingsUpdateInternal(req settings.Update) bool { return req.AuthLocalEnabled != nil || req.OidcEnabled != nil || req.AuthSessionTimeout != nil || req.AuthPasswordPolicy != nil || - req.AuthOidcConfig != nil || req.OidcClientId != nil || + req.OidcClientId != nil || req.OidcClientSecret != nil || req.OidcIssuerUrl != nil || req.OidcScopes != nil || req.OidcAdminClaim != nil || req.OidcAdminValue != nil || req.OidcMergeAccounts != nil || diff --git a/backend/api/handlers/templates.go b/backend/api/handlers/templates.go index 66d26ed4a3..dea6c411a7 100644 --- a/backend/api/handlers/templates.go +++ b/backend/api/handlers/templates.go @@ -404,7 +404,7 @@ func (h *TemplateHandler) ListTemplates(ctx context.Context, input *ListTemplate return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } diff --git a/backend/api/handlers/users.go b/backend/api/handlers/users.go index ca7199aac3..ac5c3e5821 100644 --- a/backend/api/handlers/users.go +++ b/backend/api/handlers/users.go @@ -171,7 +171,7 @@ func (h *UserHandler) ListUsers(ctx context.Context, input *ListUsersInput) (*Li return nil, err } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) users, paginationResp, err := h.userService.ListUsersPaginated(ctx, params) if err != nil { diff --git a/backend/api/handlers/vulnerabilities.go b/backend/api/handlers/vulnerabilities.go index 90273f0c89..3d0683c6a7 100644 --- a/backend/api/handlers/vulnerabilities.go +++ b/backend/api/handlers/vulnerabilities.go @@ -63,8 +63,8 @@ type ListImageVulnerabilitiesInput struct { Order string `query:"order" doc:"Sort order"` Start int `query:"start" doc:"Start offset"` Limit int `query:"limit" doc:"Limit"` - Page int `query:"page" doc:"Page number"` - Severity string `query:"severity" doc:"Comma-separated severity filter"` + + Severity string `query:"severity" doc:"Comma-separated severity filter"` } type ListImageVulnerabilitiesOutput struct { @@ -86,9 +86,9 @@ type ListAllVulnerabilitiesInput struct { Order string `query:"order" doc:"Sort order"` Start int `query:"start" doc:"Start offset"` Limit int `query:"limit" doc:"Limit"` - Page int `query:"page" doc:"Page number"` - Severity string `query:"severity" doc:"Comma-separated severity filter"` - ImageName string `query:"imageName" doc:"Filter by image/repo name (substring)"` + + Severity string `query:"severity" doc:"Comma-separated severity filter"` + ImageName string `query:"imageName" doc:"Filter by image/repo name (substring)"` } type ListAllVulnerabilitiesOutput struct { @@ -398,7 +398,7 @@ func (h *VulnerabilityHandler) ListImageVulnerabilities(ctx context.Context, inp return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(input.Page, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } @@ -459,7 +459,7 @@ func (h *VulnerabilityHandler) ListAllVulnerabilities(ctx context.Context, input return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(input.Page, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } @@ -628,7 +628,6 @@ type ListIgnoredVulnerabilitiesInput struct { Order string `query:"order" doc:"Sort order"` Start int `query:"start" doc:"Start offset"` Limit int `query:"limit" doc:"Limit"` - Page int `query:"page" doc:"Page number"` } type ListIgnoredVulnerabilitiesOutput struct { @@ -641,7 +640,7 @@ func (h *VulnerabilityHandler) ListIgnoredVulnerabilities(ctx context.Context, i return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(input.Page, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 7c9f492413..0e90b377d1 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -133,18 +133,7 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi AgentMode: cfg.AgentMode, }) startup.InitializeDefaultSettings(appCtx, runtimeCfg, appServices.Settings) - startup.MigrateSchedulerCronValues( - appCtx, - appServices.Settings.GetStringSetting, - appServices.Settings.UpdateSetting, - appServices.Settings.LoadDatabaseSettings, - ) if appServices.GitOpsSync != nil { - startup.MigrateGitOpsSyncIntervals( - appCtx, - appServices.GitOpsSync.ListSyncIntervalsRaw, - appServices.GitOpsSync.UpdateSyncIntervalMinutes, - ) if err := appServices.GitOpsSync.ReconcileDirectorySyncProjectsOnStartup(appCtx); err != nil { slog.WarnContext(appCtx, "Failed to reconcile directory GitOps projects on startup", "error", err) } @@ -205,8 +194,6 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi startup.InitializeAutoLogin(ctx, runtimeCfg) return nil }, - appServices.Settings.MigrateOidcConfigToFields, - appServices.Notification.MigrateDiscordWebhookUrlToFields, ) startup.CleanupUnknownSettings(appCtx, appServices.Settings) @@ -514,16 +501,6 @@ func normalizeTunnelGRPCRequestPathInternal(r *http.Request) *http.Request { } connectMethodPath := tunnelpb.TunnelService_Connect_FullMethodName - legacyAPIPath := "/api/tunnel/connect" - if strings.HasSuffix(r.URL.Path, legacyAPIPath) { - clone := r.Clone(r.Context()) - cloneURL := *clone.URL - cloneURL.Path = connectMethodPath - clone.URL = &cloneURL - clone.RequestURI = connectMethodPath - return clone - } - idx := strings.Index(r.URL.Path, connectMethodPath) if idx <= 0 { return r @@ -553,7 +530,7 @@ func isTunnelGRPCRequestInternal(r *http.Request) bool { path := r.URL.Path fullMethodPath := tunnelpb.TunnelService_Connect_FullMethodName - if path == fullMethodPath || strings.HasSuffix(path, fullMethodPath) || strings.HasSuffix(path, "/api/tunnel/connect") { + if path == fullMethodPath || strings.HasSuffix(path, fullMethodPath) { return true } diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go index 1d883bf210..d92f19a887 100644 --- a/backend/internal/bootstrap/bootstrap_test.go +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -43,24 +43,6 @@ func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) { assert.Equal(t, fullMethodPath, normalized.RequestURI) }) - t.Run("legacy api tunnel path maps to grpc method", func(t *testing.T) { - req := httptest.NewRequest("POST", "/api/tunnel/connect", nil) - normalized := normalizeTunnelGRPCRequestPathInternal(req) - - assert.NotSame(t, req, normalized) - assert.Equal(t, fullMethodPath, normalized.URL.Path) - assert.Equal(t, fullMethodPath, normalized.RequestURI) - }) - - t.Run("prefixed legacy api tunnel path maps to grpc method", func(t *testing.T) { - req := httptest.NewRequest("POST", "/edge/proxy/api/tunnel/connect", nil) - normalized := normalizeTunnelGRPCRequestPathInternal(req) - - assert.NotSame(t, req, normalized) - assert.Equal(t, fullMethodPath, normalized.URL.Path) - assert.Equal(t, fullMethodPath, normalized.RequestURI) - }) - t.Run("nested proxy prefix is removed up to method path", func(t *testing.T) { req := httptest.NewRequest("POST", "/edge/proxy/api"+fullMethodPath, nil) normalized := normalizeTunnelGRPCRequestPathInternal(req) @@ -91,11 +73,6 @@ func TestIsTunnelGRPCRequestInternal(t *testing.T) { assert.True(t, isTunnelGRPCRequestInternal(req)) }) - t.Run("detects by legacy tunnel path", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/tunnel/connect", nil) - assert.True(t, isTunnelGRPCRequestInternal(req)) - }) - t.Run("does not match regular api requests", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/environments/pair", nil) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 952e41d0f0..265fbfdfcf 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -208,7 +208,6 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) Port: appServices.Port, Swarm: appServices.Swarm, Notification: appServices.Notification, - Apprise: appServices.Apprise, Updater: appServices.Updater, CustomizeSearch: appServices.CustomizeSearch, System: appServices.System, diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 6928ea2d99..ea8c2400b1 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -42,7 +42,6 @@ type Services struct { Event *services.EventService Version *services.VersionService Notification *services.NotificationService - Apprise *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr ApiKey *services.ApiKeyService GitRepository *services.GitRepositoryService GitOpsSync *services.GitOpsSyncService @@ -77,7 +76,6 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event, svcs.Settings, svcs.ApiKey) svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker) svcs.Notification = services.NewNotificationService(db, cfg, svcs.Environment) - svcs.Apprise = services.NewAppriseService(db, cfg) svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification) svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification) svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Vulnerability, svcs.Event) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 1cc554184c..8948b037ca 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -423,14 +423,6 @@ func (e *AgentTokenPersistenceError) Error() string { return "Failed to persist agent token" } -type AgentPairingError struct { - Err error -} - -func (e *AgentPairingError) Error() string { - return fmt.Sprintf("Agent pairing failed: %v", e.Err) -} - type EnvironmentCreationError struct { Err error } @@ -733,28 +725,6 @@ func (e *NotificationTestError) Error() string { return fmt.Sprintf("Failed to send test notification: %v", e.Err) } -type AppriseSettingsNotFoundError struct{} - -func (e *AppriseSettingsNotFoundError) Error() string { - return "Apprise settings not found" -} - -type AppriseSettingsUpdateError struct { - Err error -} - -func (e *AppriseSettingsUpdateError) Error() string { - return fmt.Sprintf("Failed to update Apprise settings: %v", e.Err) -} - -type AppriseTestError struct { - Err error -} - -func (e *AppriseTestError) Error() string { - return fmt.Sprintf("Failed to send Apprise test notification: %v", e.Err) -} - type OidcStatusError struct { Err error } diff --git a/backend/internal/configschema/schema.go b/backend/internal/configschema/schema.go index bdd036d969..095dced737 100644 --- a/backend/internal/configschema/schema.go +++ b/backend/internal/configschema/schema.go @@ -91,41 +91,12 @@ type overrideDocRule struct { } var overrideDocRules = map[string]overrideDocRule{ - "authOidcConfig": { - deprecated: true, - note: "Deprecated legacy JSON OIDC configuration. Prefer the discrete OIDC_* environment variables.", - }, "autoUpdateInterval": { requires: "AUTO_UPDATE=true to have effect at runtime.", }, "scheduledPruneInterval": { requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", }, - "scheduledPruneContainers": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_CONTAINER_MODE.", - }, - "scheduledPruneImages": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_IMAGE_MODE.", - }, - "scheduledPruneVolumes": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_VOLUME_MODE.", - }, - "scheduledPruneNetworks": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_NETWORK_MODE.", - }, - "scheduledPruneBuildCache": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_BUILD_CACHE_MODE.", - }, "pruneContainerMode": { requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", }, @@ -153,10 +124,6 @@ var overrideDocRules = map[string]overrideDocRule{ "pruneBuildCacheUntil": { requires: "SCHEDULED_PRUNE_ENABLED=true and PRUNE_BUILD_CACHE_MODE=olderThan to have effect at runtime.", }, - "dockerPruneMode": { - deprecated: true, - note: "Legacy prune mode retained for migration compatibility. Prefer the granular scheduled prune mode settings.", - }, "vulnerabilityScanInterval": { requires: "VULNERABILITY_SCAN_ENABLED=true to have effect at runtime.", }, diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index 617b7df3fe..fbe348d0ff 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -77,13 +77,6 @@ func TestGenerate_SettingEnvOverridesMatchModelMetadata(t *testing.T) { assert.Equal(t, "arcane-mobile://oidc-callback", oidcMobileRedirectURIs.DefaultValue) assert.Equal(t, "authentication", oidcMobileRedirectURIs.Category) - legacyOIDC, ok := entries["authOidcConfig"] - require.True(t, ok) - assert.True(t, legacyOIDC.Deprecated) - assert.NotEmpty(t, legacyOIDC.Note) - assert.Contains(t, legacyOIDC.Requires, "AGENT_MODE=true") - assert.Empty(t, legacyOIDC.DefaultValue) - trivyImage, ok := entries["trivyImage"] require.True(t, ok) assert.Contains(t, trivyImage.Requires, "UI_CONFIGURATION_DISABLED=true") @@ -272,7 +265,6 @@ var expectedSettingOverrideKeys = []string{ "accentColor", "applicationTheme", "authLocalEnabled", - "authOidcConfig", "authPasswordPolicy", "authSessionTimeout", "autoHealEnabled", @@ -345,13 +337,8 @@ var expectedSettingOverrideKeys = []string{ "pruneNetworkUntil", "pruneVolumeMode", "registryTimeout", - "scheduledPruneBuildCache", - "scheduledPruneContainers", "scheduledPruneEnabled", - "scheduledPruneImages", "scheduledPruneInterval", - "scheduledPruneNetworks", - "scheduledPruneVolumes", "sidebarHoverExpansion", "swarmStackSourcesDirectory", "templatesDirectory", diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go index 9bc6a0e6a7..2f9a6f8878 100644 --- a/backend/internal/models/notification.go +++ b/backend/internal/models/notification.go @@ -209,17 +209,3 @@ type GenericConfig struct { // checked (existing behaviour). SuccessBodyContains string `json:"successBodyContains,omitempty"` } - -type AppriseSettings struct { - ID uint `json:"id" gorm:"primaryKey"` - APIURL string `json:"apiUrl" gorm:"not null"` - Enabled bool `json:"enabled" gorm:"default:false"` - ImageUpdateTag string `json:"imageUpdateTag" gorm:"type:varchar(255)"` - ContainerUpdateTag string `json:"containerUpdateTag" gorm:"type:varchar(255)"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (AppriseSettings) TableName() string { - return "apprise_settings" -} diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 8e5be48097..f1cde96f40 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -1,7 +1,6 @@ package models import ( - "encoding/json" "errors" "fmt" "reflect" @@ -13,8 +12,7 @@ import ( ) const ( - redactionMask = "XXXXXXXXXX" - keyAuthOidcConfig = "authOidcConfig" + redactionMask = "XXXXXXXXXX" ) type SettingVariable struct { @@ -92,46 +90,34 @@ type Settings struct { EventCleanupInterval SettingVariable `key:"eventCleanupInterval" meta:"label=Event Cleanup Interval;type=cron;keywords=events,cleanup,retention,interval,frequency,schedule,history,logs,jobs;description=How often to delete old events (cron expression)"` ExpiredSessionsCleanupInterval SettingVariable `key:"expiredSessionsCleanupInterval" meta:"label=Expired Sessions Cleanup Interval;type=cron;keywords=sessions,cleanup,retention,expired,revoked,interval,frequency,schedule,auth,jobs;description=How often to delete expired and old revoked sessions (cron expression)"` AutoInjectEnv SettingVariable `key:"autoInjectEnv" meta:"label=Auto Inject Env Variables;type=boolean;keywords=auto,inject,env,environment,variables,interpolation;category=internal;description=Automatically inject project .env variables into all containers (default: false)"` - // Deprecated: Use the granular prune mode settings instead. - PruneMode SettingVariable `key:"dockerPruneMode,internal,deprecated" meta:"label=Legacy Docker Prune Action;type=select;keywords=prune,cleanup,clean,remove,delete,unused,dangling,space,disk,legacy;category=internal;description=Legacy prune mode retained for compatibility and migration"` - DefaultDeployPullPolicy SettingVariable `key:"defaultDeployPullPolicy" meta:"label=Default Deploy Pull Policy;type=select;keywords=deploy,pull,policy,compose,up,missing,always;category=internal;description=Default image pull policy when deploying projects"` - ScheduledPruneEnabled SettingVariable `key:"scheduledPruneEnabled" meta:"label=Scheduled Prune Enabled;type=boolean;keywords=prune,cleanup,maintenance,schedule,automatic;category=internal;description=Enable scheduled pruning of unused Docker resources"` - ScheduledPruneInterval SettingVariable `key:"scheduledPruneInterval" meta:"label=Scheduled Prune Interval;type=cron;keywords=prune,cleanup,interval,minutes,schedule;category=internal;description=How often to run scheduled prunes (cron expression)"` - GitopsSyncInterval SettingVariable `key:"gitopsSyncInterval" meta:"label=GitOps Sync Interval;type=cron;keywords=gitops,sync,interval,frequency,schedule,repository;category=internal;description=How often to run GitOps synchronization checks (cron expression)"` - // Deprecated: Use pruneContainerMode instead. - ScheduledPruneContainers SettingVariable `key:"scheduledPruneContainers,deprecated" meta:"label=Legacy Scheduled Prune Containers;type=boolean;keywords=prune,containers,cleanup,maintenance,legacy;category=internal;description=Legacy boolean container prune flag retained for migration compatibility"` - // Deprecated: Use pruneImageMode instead. - ScheduledPruneImages SettingVariable `key:"scheduledPruneImages,deprecated" meta:"label=Legacy Scheduled Prune Images;type=boolean;keywords=prune,images,cleanup,maintenance,legacy;category=internal;description=Legacy boolean image prune flag retained for migration compatibility"` - // Deprecated: Use pruneVolumeMode instead. - ScheduledPruneVolumes SettingVariable `key:"scheduledPruneVolumes,deprecated" meta:"label=Legacy Scheduled Prune Volumes;type=boolean;keywords=prune,volumes,cleanup,maintenance,legacy;category=internal;description=Legacy boolean volume prune flag retained for migration compatibility"` - // Deprecated: Use pruneNetworkMode instead. - ScheduledPruneNetworks SettingVariable `key:"scheduledPruneNetworks,deprecated" meta:"label=Legacy Scheduled Prune Networks;type=boolean;keywords=prune,networks,cleanup,maintenance,legacy;category=internal;description=Legacy boolean network prune flag retained for migration compatibility"` - // Deprecated: Use pruneBuildCacheMode instead. - ScheduledPruneBuildCache SettingVariable `key:"scheduledPruneBuildCache,deprecated" meta:"label=Legacy Scheduled Prune Build Cache;type=boolean;keywords=prune,build cache,cleanup,maintenance,legacy;category=internal;description=Legacy boolean build cache prune flag retained for migration compatibility"` - PruneContainerMode SettingVariable `key:"pruneContainerMode" meta:"label=Prune Containers;type=select;keywords=prune,containers,cleanup,maintenance,mode,older,stopped;category=internal;description=Select how containers should be pruned when the scheduled prune job runs"` - PruneContainerUntil SettingVariable `key:"pruneContainerUntil" meta:"label=Container Age Filter;type=text;keywords=prune,containers,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled container prune when mode is olderThan"` - PruneImageMode SettingVariable `key:"pruneImageMode" meta:"label=Prune Images;type=select;keywords=prune,images,cleanup,maintenance,mode,dangling,all,older;category=internal;description=Select how images should be pruned when the scheduled prune job runs"` - PruneImageUntil SettingVariable `key:"pruneImageUntil" meta:"label=Image Age Filter;type=text;keywords=prune,images,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled image prune when mode is olderThan"` - PruneVolumeMode SettingVariable `key:"pruneVolumeMode" meta:"label=Prune Volumes;type=select;keywords=prune,volumes,cleanup,maintenance,mode,anonymous,named;category=internal;description=Select how volumes should be pruned when the scheduled prune job runs"` - PruneNetworkMode SettingVariable `key:"pruneNetworkMode" meta:"label=Prune Networks;type=select;keywords=prune,networks,cleanup,maintenance,mode,unused,older;category=internal;description=Select how networks should be pruned when the scheduled prune job runs"` - PruneNetworkUntil SettingVariable `key:"pruneNetworkUntil" meta:"label=Network Age Filter;type=text;keywords=prune,networks,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled network prune when mode is olderThan"` - PruneBuildCacheMode SettingVariable `key:"pruneBuildCacheMode" meta:"label=Prune Build Cache;type=select;keywords=prune,build cache,cleanup,maintenance,mode,unused,all,older;category=internal;description=Select how build cache should be pruned when the scheduled prune job runs"` - PruneBuildCacheUntil SettingVariable `key:"pruneBuildCacheUntil" meta:"label=Build Cache Age Filter;type=text;keywords=prune,build cache,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled build cache prune when mode is olderThan"` - AutoHealEnabled SettingVariable `key:"autoHealEnabled" meta:"label=Auto Heal;type=boolean;keywords=auto,heal,health,restart,unhealthy,recovery,container,healthcheck;category=internal;description=Automatically restart containers that become unhealthy"` - AutoHealInterval SettingVariable `key:"autoHealInterval" meta:"label=Auto Heal Interval;type=cron;keywords=auto,heal,interval,frequency,schedule,health,jobs;description=How often to check container health (cron expression)" catmeta:"id=jobschedule"` - AutoHealExcludedContainers SettingVariable `key:"autoHealExcludedContainers" meta:"label=Auto Heal Excluded Containers;type=text;keywords=auto,heal,exclude,containers,ignore,skip,health;category=internal;description=Comma-separated list of containers to exclude from auto-heal"` - AutoHealMaxRestarts SettingVariable `key:"autoHealMaxRestarts" meta:"label=Auto Heal Max Restarts;type=number;keywords=auto,heal,max,restarts,limit,loop,protection;category=internal;description=Maximum auto-heal restarts per container within the restart window (default: 5)"` - AutoHealRestartWindow SettingVariable `key:"autoHealRestartWindow" meta:"label=Auto Heal Restart Window;type=number;keywords=auto,heal,restart,window,minutes,cooldown,protection;category=internal;description=Time window in minutes for counting auto-heal restarts (default: 30)"` - MaxImageUploadSize SettingVariable `key:"maxImageUploadSize" meta:"label=Max Image Upload Size;type=number;keywords=upload,size,limit,maximum,image,tar,file,megabytes,mb,storage;category=internal;description=Maximum size in MB for image archive uploads (default: 500)"` - GitSyncMaxFiles SettingVariable `key:"gitSyncMaxFiles,envOverride" meta:"label=Git Sync Max Files;type=number;keywords=git,sync,files,limit,repository,compose,gitops;category=general;description=Maximum number of repository files copied during a Git sync. Set 0 to disable the environment cap (default: 500)"` - GitSyncMaxTotalSizeMb SettingVariable `key:"gitSyncMaxTotalSizeMb,envOverride" meta:"label=Git Sync Max Total Size (MB);type=number;keywords=git,sync,size,limit,repository,compose,gitops,mb;category=general;description=Maximum combined size in MB for files copied during a Git sync. Set 0 to disable the environment cap (default: 50)"` - GitSyncMaxBinarySizeMb SettingVariable `key:"gitSyncMaxBinarySizeMb,envOverride" meta:"label=Git Sync Max Binary Size (MB);type=number;keywords=git,sync,binary,size,limit,repository,compose,gitops,mb;category=general;description=Maximum size in MB for a single binary file copied during a Git sync. Set 0 to disable the environment cap (default: 10)"` - DockerHost SettingVariable `key:"dockerHost,authrequired,envOverride" meta:"label=Docker Host;type=text;keywords=docker,host,daemon,socket,unix,remote;category=internal;description=URI for Docker daemon"` - BuildProvider SettingVariable `key:"buildProvider,envOverride" meta:"label=Build Provider;type=select;keywords=build,buildkit,depot,provider,remote,local;category=build;description=Default build provider (local or depot)" catmeta:"id=build;title=Build;icon=code;url=/settings/builds;description=Configure BuildKit and Depot build settings"` - BuildsDirectory SettingVariable `key:"buildsDirectory,envOverride" meta:"label=Builds Directory;type=text;keywords=builds,directory,path,workspace,context;category=build;description=Root directory for manual build workspaces"` - BuildTimeout SettingVariable `key:"buildTimeout,envOverride" meta:"label=Build Timeout;type=number;keywords=build,timeout,seconds,buildkit;category=build;description=Timeout for BuildKit builds in seconds (default: 1800 = 30 minutes)"` - DepotProjectId SettingVariable `key:"depotProjectId,envOverride" meta:"label=Depot Project ID;type=text;keywords=depot,project,id,build,provider;category=build;description=Depot project identifier"` - DepotToken SettingVariable `key:"depotToken,envOverride,sensitive" meta:"label=Depot Token;type=password;keywords=depot,token,api,secret,build,provider;category=build;description=Depot API token"` + DefaultDeployPullPolicy SettingVariable `key:"defaultDeployPullPolicy" meta:"label=Default Deploy Pull Policy;type=select;keywords=deploy,pull,policy,compose,up,missing,always;category=internal;description=Default image pull policy when deploying projects"` + ScheduledPruneEnabled SettingVariable `key:"scheduledPruneEnabled" meta:"label=Scheduled Prune Enabled;type=boolean;keywords=prune,cleanup,maintenance,schedule,automatic;category=internal;description=Enable scheduled pruning of unused Docker resources"` + ScheduledPruneInterval SettingVariable `key:"scheduledPruneInterval" meta:"label=Scheduled Prune Interval;type=cron;keywords=prune,cleanup,interval,minutes,schedule;category=internal;description=How often to run scheduled prunes (cron expression)"` + GitopsSyncInterval SettingVariable `key:"gitopsSyncInterval" meta:"label=GitOps Sync Interval;type=cron;keywords=gitops,sync,interval,frequency,schedule,repository;category=internal;description=How often to run GitOps synchronization checks (cron expression)"` + PruneContainerMode SettingVariable `key:"pruneContainerMode" meta:"label=Prune Containers;type=select;keywords=prune,containers,cleanup,maintenance,mode,older,stopped;category=internal;description=Select how containers should be pruned when the scheduled prune job runs"` + PruneContainerUntil SettingVariable `key:"pruneContainerUntil" meta:"label=Container Age Filter;type=text;keywords=prune,containers,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled container prune when mode is olderThan"` + PruneImageMode SettingVariable `key:"pruneImageMode" meta:"label=Prune Images;type=select;keywords=prune,images,cleanup,maintenance,mode,dangling,all,older;category=internal;description=Select how images should be pruned when the scheduled prune job runs"` + PruneImageUntil SettingVariable `key:"pruneImageUntil" meta:"label=Image Age Filter;type=text;keywords=prune,images,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled image prune when mode is olderThan"` + PruneVolumeMode SettingVariable `key:"pruneVolumeMode" meta:"label=Prune Volumes;type=select;keywords=prune,volumes,cleanup,maintenance,mode,anonymous,named;category=internal;description=Select how volumes should be pruned when the scheduled prune job runs"` + PruneNetworkMode SettingVariable `key:"pruneNetworkMode" meta:"label=Prune Networks;type=select;keywords=prune,networks,cleanup,maintenance,mode,unused,older;category=internal;description=Select how networks should be pruned when the scheduled prune job runs"` + PruneNetworkUntil SettingVariable `key:"pruneNetworkUntil" meta:"label=Network Age Filter;type=text;keywords=prune,networks,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled network prune when mode is olderThan"` + PruneBuildCacheMode SettingVariable `key:"pruneBuildCacheMode" meta:"label=Prune Build Cache;type=select;keywords=prune,build cache,cleanup,maintenance,mode,unused,all,older;category=internal;description=Select how build cache should be pruned when the scheduled prune job runs"` + PruneBuildCacheUntil SettingVariable `key:"pruneBuildCacheUntil" meta:"label=Build Cache Age Filter;type=text;keywords=prune,build cache,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled build cache prune when mode is olderThan"` + AutoHealEnabled SettingVariable `key:"autoHealEnabled" meta:"label=Auto Heal;type=boolean;keywords=auto,heal,health,restart,unhealthy,recovery,container,healthcheck;category=internal;description=Automatically restart containers that become unhealthy"` + AutoHealInterval SettingVariable `key:"autoHealInterval" meta:"label=Auto Heal Interval;type=cron;keywords=auto,heal,interval,frequency,schedule,health,jobs;description=How often to check container health (cron expression)" catmeta:"id=jobschedule"` + AutoHealExcludedContainers SettingVariable `key:"autoHealExcludedContainers" meta:"label=Auto Heal Excluded Containers;type=text;keywords=auto,heal,exclude,containers,ignore,skip,health;category=internal;description=Comma-separated list of containers to exclude from auto-heal"` + AutoHealMaxRestarts SettingVariable `key:"autoHealMaxRestarts" meta:"label=Auto Heal Max Restarts;type=number;keywords=auto,heal,max,restarts,limit,loop,protection;category=internal;description=Maximum auto-heal restarts per container within the restart window (default: 5)"` + AutoHealRestartWindow SettingVariable `key:"autoHealRestartWindow" meta:"label=Auto Heal Restart Window;type=number;keywords=auto,heal,restart,window,minutes,cooldown,protection;category=internal;description=Time window in minutes for counting auto-heal restarts (default: 30)"` + MaxImageUploadSize SettingVariable `key:"maxImageUploadSize" meta:"label=Max Image Upload Size;type=number;keywords=upload,size,limit,maximum,image,tar,file,megabytes,mb,storage;category=internal;description=Maximum size in MB for image archive uploads (default: 500)"` + GitSyncMaxFiles SettingVariable `key:"gitSyncMaxFiles,envOverride" meta:"label=Git Sync Max Files;type=number;keywords=git,sync,files,limit,repository,compose,gitops;category=general;description=Maximum number of repository files copied during a Git sync. Set 0 to disable the environment cap (default: 500)"` + GitSyncMaxTotalSizeMb SettingVariable `key:"gitSyncMaxTotalSizeMb,envOverride" meta:"label=Git Sync Max Total Size (MB);type=number;keywords=git,sync,size,limit,repository,compose,gitops,mb;category=general;description=Maximum combined size in MB for files copied during a Git sync. Set 0 to disable the environment cap (default: 50)"` + GitSyncMaxBinarySizeMb SettingVariable `key:"gitSyncMaxBinarySizeMb,envOverride" meta:"label=Git Sync Max Binary Size (MB);type=number;keywords=git,sync,binary,size,limit,repository,compose,gitops,mb;category=general;description=Maximum size in MB for a single binary file copied during a Git sync. Set 0 to disable the environment cap (default: 10)"` + DockerHost SettingVariable `key:"dockerHost,authrequired,envOverride" meta:"label=Docker Host;type=text;keywords=docker,host,daemon,socket,unix,remote;category=internal;description=URI for Docker daemon"` + BuildProvider SettingVariable `key:"buildProvider,envOverride" meta:"label=Build Provider;type=select;keywords=build,buildkit,depot,provider,remote,local;category=build;description=Default build provider (local or depot)" catmeta:"id=build;title=Build;icon=code;url=/settings/builds;description=Configure BuildKit and Depot build settings"` + BuildsDirectory SettingVariable `key:"buildsDirectory,envOverride" meta:"label=Builds Directory;type=text;keywords=builds,directory,path,workspace,context;category=build;description=Root directory for manual build workspaces"` + BuildTimeout SettingVariable `key:"buildTimeout,envOverride" meta:"label=Build Timeout;type=number;keywords=build,timeout,seconds,buildkit;category=build;description=Timeout for BuildKit builds in seconds (default: 1800 = 30 minutes)"` + DepotProjectId SettingVariable `key:"depotProjectId,envOverride" meta:"label=Depot Project ID;type=text;keywords=depot,project,id,build,provider;category=build;description=Depot project identifier"` + DepotToken SettingVariable `key:"depotToken,envOverride,sensitive" meta:"label=Depot Token;type=password;keywords=depot,token,api,secret,build,provider;category=build;description=Depot API token"` // Authentication and security categories AuthLocalEnabled SettingVariable `key:"authLocalEnabled,public" meta:"label=Local Authentication;type=boolean;keywords=local,auth,authentication,username,password,login,credentials;category=authentication;description=Enable local username/password authentication" catmeta:"id=authentication;title=Authentication;icon=lock;url=/settings/authentication;description=Manage authentication providers, password policy, and session behavior"` @@ -150,7 +136,6 @@ type Settings struct { TrivyConcurrentScanContainers SettingVariable `key:"trivyConcurrentScanContainers,envOverride" meta:"label=Trivy Concurrent Scan Containers;type=number;keywords=trivy,concurrent,scan,containers,parallel,workers,limit,security;category=security;description=Maximum number of concurrent Trivy scan containers for manual and scheduled scans. Minimum 1"` TrivyConfig SettingVariable `key:"trivyConfig" meta:"label=Trivy Config (YAML);type=textarea;keywords=trivy,config,yaml,configuration,scanner,settings;category=security;description=Trivy configuration file content in YAML format"` TrivyIgnore SettingVariable `key:"trivyIgnore" meta:"label=.trivyignore;type=textarea;keywords=trivy,ignore,ignorefile,vulnerabilities,exceptions,exclusions;category=security;description=Trivy ignore file content - one vulnerability ID per line"` - AuthOidcConfig SettingVariable `key:"authOidcConfig,sensitive,deprecated" meta:"label=OIDC Config;type=text;keywords=oidc,config,client,id,issuer,secret,oauth;category=authentication;description=OIDC provider configuration (deprecated - use individual fields)"` OidcEnabled SettingVariable `key:"oidcEnabled,public,envOverride" meta:"label=OIDC Authentication;type=boolean;keywords=oidc,openid,connect,sso,oauth,external,provider,federation;category=authentication;description=Enable OpenID Connect (OIDC) authentication"` OidcClientId SettingVariable `key:"oidcClientId,authrequired,envOverride" meta:"label=OIDC Client ID;type=text;keywords=oidc,client,id,oauth,openid;category=authentication;description=OIDC provider client ID"` OidcClientSecret SettingVariable `key:"oidcClientSecret,sensitive,envOverride" meta:"label=OIDC Client Secret;type=password;keywords=oidc,client,secret,oauth,openid;category=authentication;description=OIDC provider client secret"` @@ -268,7 +253,7 @@ func (s *Settings) ToSettingVariableSlice(visibility SettingVisibility, redactSe } value := cfgValue.Field(field.index).FieldByName("Value").String() - value = redactSettingValue(field.key, value, field.attrs, redactSensitiveValues) + value = redactSettingValue(value, field.attrs, redactSensitiveValues) settingVariable := SettingVariable{ Key: field.key, @@ -339,23 +324,11 @@ func (s *Settings) UpdateField(key string, value string, noSensitive bool) error } // helper keeps redaction logic in one place; behavior unchanged -func redactSettingValue(key, value, attrs string, redact bool) string { +func redactSettingValue(value, attrs string, redact bool) string { if value == "" || !redact || !strings.Contains(attrs, "sensitive") { return value } - if key == keyAuthOidcConfig { - var cfg OidcConfig - if err := json.Unmarshal([]byte(value), &cfg); err == nil { - cfg.ClientSecret = "" - if redacted, err := cfg.MarshalDocument(); err == nil { - return string(redacted) - } - return redactionMask - } - return redactionMask - } - return redactionMask } @@ -407,34 +380,3 @@ type OidcConfig struct { SkipTlsVerify bool `json:"skipTlsVerify"` } - -// MarshalDocument preserves the legacy json.Marshal(OidcConfig) document shape, -// including omitempty behavior for optional string fields, while avoiding gosec -// false positives on the clientSecret field. -func (c OidcConfig) MarshalDocument() ([]byte, error) { - doc := map[string]any{ - "clientId": c.ClientID, - "clientSecret": c.ClientSecret, - "issuerUrl": c.IssuerURL, - "scopes": c.Scopes, - "skipTlsVerify": c.SkipTlsVerify, - } - - addOptionalStringFieldInternal(doc, "authorizationEndpoint", c.AuthorizationEndpoint) - addOptionalStringFieldInternal(doc, "tokenEndpoint", c.TokenEndpoint) - addOptionalStringFieldInternal(doc, "userinfoEndpoint", c.UserinfoEndpoint) - addOptionalStringFieldInternal(doc, "jwksUri", c.JwksURI) - addOptionalStringFieldInternal(doc, "deviceAuthorizationEndpoint", c.DeviceAuthorizationEndpoint) - addOptionalStringFieldInternal(doc, "adminClaim", c.AdminClaim) - addOptionalStringFieldInternal(doc, "adminValue", c.AdminValue) - - return json.Marshal(doc) -} - -func addOptionalStringFieldInternal(doc map[string]any, key, value string) { - if value == "" { - return - } - - doc[key] = value -} diff --git a/backend/internal/models/settings_test.go b/backend/internal/models/settings_test.go index 156ce15c99..97e214ae2f 100644 --- a/backend/internal/models/settings_test.go +++ b/backend/internal/models/settings_test.go @@ -1,48 +1,11 @@ package models import ( - "encoding/json" "testing" "github.com/stretchr/testify/require" ) -func TestOidcConfig_MarshalDocument_PreservesOmitemptySemantics(t *testing.T) { - config := OidcConfig{ - ClientID: "client-id", - ClientSecret: "client-secret", - IssuerURL: "https://issuer.example", - Scopes: "openid email profile", - AuthorizationEndpoint: "", - TokenEndpoint: "https://issuer.example/token", - UserinfoEndpoint: "", - JwksURI: "https://issuer.example/jwks", - AdminClaim: "", - AdminValue: "admins", - SkipTlsVerify: true, - } - - data, err := config.MarshalDocument() - require.NoError(t, err) - - var doc map[string]any - require.NoError(t, json.Unmarshal(data, &doc)) - - require.Equal(t, "client-id", doc["clientId"]) - require.Equal(t, "client-secret", doc["clientSecret"]) - require.Equal(t, "https://issuer.example", doc["issuerUrl"]) - require.Equal(t, "openid email profile", doc["scopes"]) - require.Equal(t, true, doc["skipTlsVerify"]) - - require.NotContains(t, doc, "authorizationEndpoint") - require.NotContains(t, doc, "userinfoEndpoint") - require.NotContains(t, doc, "adminClaim") - - require.Equal(t, "https://issuer.example/token", doc["tokenEndpoint"]) - require.Equal(t, "https://issuer.example/jwks", doc["jwksUri"]) - require.Equal(t, "admins", doc["adminValue"]) -} - func TestSettings_ToSettingVariableSlice_Visibility(t *testing.T) { settings := &Settings{ ApplicationTheme: SettingVariable{Value: "default"}, diff --git a/backend/internal/services/apprise_service.go b/backend/internal/services/apprise_service.go deleted file mode 100644 index 6cf858e085..0000000000 --- a/backend/internal/services/apprise_service.go +++ /dev/null @@ -1,255 +0,0 @@ -package services - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "time" - - "github.com/getarcaneapp/arcane/backend/internal/config" - "github.com/getarcaneapp/arcane/backend/internal/database" - "github.com/getarcaneapp/arcane/backend/internal/models" - "github.com/getarcaneapp/arcane/backend/pkg/utils/notifications" - "github.com/getarcaneapp/arcane/types/imageupdate" - "gorm.io/gorm" -) - -// AppriseService handles sending notifications through Apprise API -// -// Deprecated: Built-in providers (e.g., SMTP via Shoutrrr) are preferred. -type AppriseService struct { - db *database.DB - config *config.Config -} - -func NewAppriseService(db *database.DB, cfg *config.Config) *AppriseService { - return &AppriseService{ - db: db, - config: cfg, - } -} - -type AppriseNotificationPayload struct { - Body string `json:"body"` - Title string `json:"title,omitempty"` - Type string `json:"type,omitempty"` - Tag []string `json:"tag,omitempty"` - Format string `json:"format,omitempty"` -} - -func (s *AppriseService) GetSettings(ctx context.Context) (*models.AppriseSettings, error) { - var settings models.AppriseSettings - if err := s.db.WithContext(ctx).First(&settings).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, err - } - return &settings, nil -} - -func (s *AppriseService) CreateOrUpdateSettings(ctx context.Context, apiURL string, enabled bool, imageUpdateTag, containerUpdateTag string) (*models.AppriseSettings, error) { - var settings models.AppriseSettings - - err := s.db.WithContext(ctx).First(&settings).Error - if err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to check apprise settings: %w", err) - } - settings = models.AppriseSettings{ - APIURL: apiURL, - Enabled: enabled, - ImageUpdateTag: imageUpdateTag, - ContainerUpdateTag: containerUpdateTag, - } - if err := s.db.WithContext(ctx).Create(&settings).Error; err != nil { - return nil, fmt.Errorf("failed to create apprise settings: %w", err) - } - } else { - settings.APIURL = apiURL - settings.Enabled = enabled - settings.ImageUpdateTag = imageUpdateTag - settings.ContainerUpdateTag = containerUpdateTag - if err := s.db.WithContext(ctx).Save(&settings).Error; err != nil { - return nil, fmt.Errorf("failed to update apprise settings: %w", err) - } - } - - return &settings, nil -} - -func (s *AppriseService) SendNotification(ctx context.Context, title, body, format string, notificationType models.NotificationEventType) error { - settings, err := s.GetSettings(ctx) - if err != nil { - return fmt.Errorf("failed to get apprise settings: %w", err) - } - - if settings == nil || !settings.Enabled { - return nil - } - - if settings.APIURL == "" { - return fmt.Errorf("apprise API URL not configured") - } - - var tags []string - switch notificationType { - case models.NotificationEventImageUpdate: - if settings.ImageUpdateTag != "" { - tags = []string{settings.ImageUpdateTag} - } - case models.NotificationEventContainerUpdate: - if settings.ContainerUpdateTag != "" { - tags = []string{settings.ContainerUpdateTag} - } - - case models.NotificationEventPruneReport: - // Handle tags for prune report if needed, or leave empty - - case models.NotificationEventVulnerabilityFound: - // No dedicated tag in AppriseSettings; notification is sent without a tag - - case models.NotificationEventAutoHeal: - // No dedicated tag in AppriseSettings; notification is sent without a tag - } - - payload := AppriseNotificationPayload{ - Title: title, - Body: body, - Type: "info", - Tag: tags, - Format: format, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal notification payload: %w", err) - } - - slog.InfoContext(ctx, "Sending Apprise notification", "url", settings.APIURL, "title", title, "tags", tags, "type", string(notificationType)) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, settings.APIURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) //nolint:gosec // intentional request to user-configured Apprise API endpoint - if err != nil { - return fmt.Errorf("failed to send notification: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Read response body for debugging - bodyBytes, _ := io.ReadAll(resp.Body) - bodyString := string(bodyBytes) - - if resp.StatusCode != http.StatusOK { - slog.ErrorContext(ctx, "Apprise API returned error", "status", resp.StatusCode, "response", bodyString, "url", settings.APIURL) - return fmt.Errorf("apprise API returned status %d: %s", resp.StatusCode, bodyString) - } - - slog.InfoContext(ctx, "Apprise notification sent successfully", "status", resp.StatusCode, "response", bodyString) - - return nil -} - -func (s *AppriseService) SendImageUpdateNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response) error { - title := fmt.Sprintf("[%s] Container Image Update Available: %s", environmentName, imageRef) - body := notifications.BuildImageUpdateNotificationMessage(notifications.MessageFormatPlain, environmentName, imageRef, updateInfo) - return s.SendNotification(ctx, title, body, "text", models.NotificationEventImageUpdate) -} - -func (s *AppriseService) SendContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string) error { - title := fmt.Sprintf("[%s] Container Updated: %s", environmentName, containerName) - body := notifications.BuildContainerUpdateNotificationMessage(notifications.MessageFormatPlain, environmentName, containerName, imageRef, oldDigest, newDigest) - return s.SendNotification(ctx, title, body, "text", models.NotificationEventContainerUpdate) -} - -func (s *AppriseService) SendBatchImageUpdateNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response) error { - if len(updates) == 0 { - return nil - } - - updatesWithChanges := make(map[string]*imageupdate.Response) - for imageRef, update := range updates { - if update != nil && update.HasUpdate { - updatesWithChanges[imageRef] = update - } - } - - if len(updatesWithChanges) == 0 { - return nil - } - - title := fmt.Sprintf("[%s] %d Container Image Update(s) Available", environmentName, len(updatesWithChanges)) - body := notifications.BuildBatchImageUpdateNotificationMessage(notifications.MessageFormatPlain, environmentName, updatesWithChanges) - - return s.SendNotification(ctx, title, body, "text", models.NotificationEventImageUpdate) -} - -func (s *AppriseService) TestNotification(ctx context.Context, environmentName, testType string) error { - switch testType { - case "vulnerability-found": - title := notifications.BuildEmailSubject(environmentName, "Vulnerability Summary Notification") - body := fmt.Sprintf( - "Summary Date: %s\nCritical: 1\nHigh: 3\nMedium: 2\nLow: 1\nUnknown: 0\nFixable vulnerabilities: 7\nExamples: CVE-2025-1234, CVE-2025-5678, CVE-2026-0001", - time.Now().UTC().Format("2006-01-02"), - ) - return s.SendNotification(ctx, title, body, "text", models.NotificationEventVulnerabilityFound) - case "prune-report": - title := notifications.BuildEmailSubject(environmentName, "System Prune Report") - body := "Containers pruned: 2\nImages deleted: 1\nVolumes deleted: 1\nNetworks deleted: 1\nSpace reclaimed: 3.56 GB" - return s.SendNotification(ctx, title, body, "text", models.NotificationEventPruneReport) - case "image-update": - testUpdate := &imageupdate.Response{ - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:abc123def456789012345678901234567890", - LatestDigest: "sha256:xyz789ghi012345678901234567890123456", - CheckTime: time.Now(), - ResponseTimeMs: 100, - } - return s.SendImageUpdateNotification(ctx, environmentName, "nginx:latest", testUpdate) - case "batch-image-update": - testUpdates := map[string]*imageupdate.Response{ - "nginx:latest": { - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:abc123def456789012345678901234567890", - LatestDigest: "sha256:xyz789ghi012345678901234567890123456", - CheckTime: time.Now(), - ResponseTimeMs: 100, - }, - "postgres:16-alpine": { - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:def456abc123789012345678901234567890", - LatestDigest: "sha256:ghi789xyz012345678901234567890123456", - CheckTime: time.Now(), - ResponseTimeMs: 120, - }, - "redis:7.2-alpine": { - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:123456789abc012345678901234567890def", - LatestDigest: "sha256:456789012def345678901234567890123abc", - CheckTime: time.Now(), - ResponseTimeMs: 95, - }, - } - return s.SendBatchImageUpdateNotification(ctx, environmentName, testUpdates) - case "simple", "": - title := notifications.BuildEmailSubject(environmentName, "Test Notification from Arcane") - body := "If you're reading this, your Apprise integration is working correctly!" - return s.SendNotification(ctx, title, body, "text", models.NotificationEventImageUpdate) - default: - return fmt.Errorf("unsupported apprise test type: %s", testType) - } -} diff --git a/backend/internal/services/environment_service.go b/backend/internal/services/environment_service.go index 312acf0d19..df226c2bc5 100644 --- a/backend/internal/services/environment_service.go +++ b/backend/internal/services/environment_service.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "log/slog" "net/http" "strings" @@ -971,64 +970,6 @@ func (s *EnvironmentService) RegenerateEnvironmentApiKey(ctx context.Context, en return nil } -// Deprecated - Use the Api Key flow -func (s *EnvironmentService) PairAgentWithBootstrap(ctx context.Context, apiUrl, bootstrapToken string) (string, error) { - pairURL, err := buildEnvironmentEndpointURLInternal(apiUrl, "/api/environments/0/agent/pair") - if err != nil { - return "", fmt.Errorf("invalid agent API URL: %w", err) - } - - reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, pairURL, nil) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - req.Header.Set("X-Arcane-Agent-Bootstrap", bootstrapToken) - - resp, err := s.httpClient.Do(req) //nolint:gosec // intentional request to configured remote environment API URL - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) - } - - var parsed struct { - Success bool `json:"success"` - Data struct { - Token string `json:"token"` - } `json:"data"` - Message string `json:"message"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return "", fmt.Errorf("decode response: %w", err) - } - if !parsed.Success || parsed.Data.Token == "" { - return "", fmt.Errorf("pairing unsuccessful") - } - - return parsed.Data.Token, nil -} - -func (s *EnvironmentService) PairAndPersistAgentToken(ctx context.Context, environmentID, apiUrl, bootstrapToken string) (string, error) { - token, err := s.PairAgentWithBootstrap(ctx, apiUrl, bootstrapToken) - if err != nil { - return "", err - } - if err := s.db.WithContext(ctx). - Model(&models.Environment{}). - Where("id = ?", environmentID). - Update("access_token", token).Error; err != nil { - return "", fmt.Errorf("failed to persist agent token: %w", err) - } - s.syncEnvironmentTokenCacheInternal(environmentID, token) - return token, nil -} - func (s *EnvironmentService) GetDB() *database.DB { return s.db } diff --git a/backend/internal/services/environment_service_test.go b/backend/internal/services/environment_service_test.go index 4ad4174642..0c7d3ac1c9 100644 --- a/backend/internal/services/environment_service_test.go +++ b/backend/internal/services/environment_service_test.go @@ -929,14 +929,6 @@ func TestEnvironmentService_GenerateEdgeDeploymentSnippets_ReturnsBasicSnippetsW require.Contains(t, snippets.DockerCompose, "MANAGER_API_URL=https://manager.example.com") } -func TestEnvironmentService_PairAgentWithBootstrap_RejectsInvalidURL(t *testing.T) { - svc := NewEnvironmentService(nil, nil, nil, nil, nil, nil) - - _, err := svc.PairAgentWithBootstrap(context.Background(), "ftp://example.com", "bootstrap-token") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid agent API URL") -} - func TestEnvironmentService_TestConnection_RejectsInvalidCustomURL(t *testing.T) { ctx := context.Background() db := setupEnvironmentServiceTestDB(t) diff --git a/backend/internal/services/gitops_sync_service.go b/backend/internal/services/gitops_sync_service.go index 1ed9d51177..25b9150721 100644 --- a/backend/internal/services/gitops_sync_service.go +++ b/backend/internal/services/gitops_sync_service.go @@ -15,7 +15,6 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" - "github.com/getarcaneapp/arcane/backend/pkg/libarcane/startup" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -147,42 +146,6 @@ func (s *GitOpsSyncService) gitSyncLimitEnvOverrideActiveInternal(key string) bo return s.settingsService != nil && s.settingsService.isEnvOverrideActiveInternal(key) } -func (s *GitOpsSyncService) ListSyncIntervalsRaw(ctx context.Context) ([]startup.IntervalMigrationItem, error) { - rows, err := s.db.WithContext(ctx).Raw("SELECT id, sync_interval FROM gitops_syncs").Rows() - if err != nil { - return nil, fmt.Errorf("failed to load git sync intervals: %w", err) - } - defer func() { _ = rows.Close() }() - - items := make([]startup.IntervalMigrationItem, 0) - for rows.Next() { - var id string - var raw any - if err := rows.Scan(&id, &raw); err != nil { - return nil, fmt.Errorf("failed to scan git sync interval: %w", err) - } - items = append(items, startup.IntervalMigrationItem{ - ID: id, - RawValue: strings.TrimSpace(fmt.Sprint(raw)), - }) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("failed to read git sync intervals: %w", err) - } - - return items, nil -} - -func (s *GitOpsSyncService) UpdateSyncIntervalMinutes(ctx context.Context, id string, minutes int) error { - if minutes <= 0 { - return fmt.Errorf("sync interval must be positive") - } - return s.db.WithContext(ctx). - Model(&models.GitOpsSync{}). - Where("id = ?", id). - Update("sync_interval", minutes).Error -} - func (s *GitOpsSyncService) GetSyncsPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]gitops.GitOpsSync, pagination.Response, gitops.SyncCounts, error) { var syncs []models.GitOpsSync q := s.db.WithContext(ctx).Model(&models.GitOpsSync{}). diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 4ef266e21a..df29bb8d9d 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -14,8 +14,6 @@ import ( "text/template" "time" - "gorm.io/gorm" - "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -66,7 +64,6 @@ type NotificationService struct { db *database.DB config *config.Config environmentSvc *EnvironmentService - appriseService *AppriseService httpClient *http.Client } @@ -93,7 +90,6 @@ func NewNotificationService(db *database.DB, cfg *config.Config, environmentSvc db: db, config: cfg, environmentSvc: environmentSvc, - appriseService: NewAppriseService(db, cfg), httpClient: &http.Client{Timeout: 15 * time.Second}, } } @@ -325,11 +321,6 @@ func (s *NotificationService) SendImageUpdateNotification(ctx context.Context, i } func (s *NotificationService) sendImageUpdateNotificationForTargetInternal(ctx context.Context, target NotificationTarget, imageRef string, updateInfo *imageupdate.Response, eventType models.NotificationEventType) error { - // Send to Apprise if enabled (don't block on error) - if appriseErr := s.appriseService.SendImageUpdateNotification(ctx, target.EnvironmentName, imageRef, updateInfo); appriseErr != nil { - slog.WarnContext(ctx, "Failed to send Apprise notification", "error", appriseErr) - } - settings, err := s.GetAllSettings(ctx) if err != nil { return fmt.Errorf("failed to get notification settings: %w", err) @@ -445,11 +436,6 @@ func (s *NotificationService) SendContainerUpdateNotification(ctx context.Contex } func (s *NotificationService) sendContainerUpdateNotificationForTargetInternal(ctx context.Context, target NotificationTarget, containerName, imageRef, oldDigest, newDigest string) error { - // Send to Apprise if enabled (don't block on error) - if appriseErr := s.appriseService.SendContainerUpdateNotification(ctx, target.EnvironmentName, containerName, imageRef, oldDigest, newDigest); appriseErr != nil { - slog.WarnContext(ctx, "Failed to send Apprise notification", "error", appriseErr) - } - settings, err := s.GetAllSettings(ctx) if err != nil { return fmt.Errorf("failed to get notification settings: %w", err) @@ -633,11 +619,11 @@ func (s *NotificationService) sendDiscordNotification(ctx context.Context, envir // Decrypt token if encrypted if discordConfig.Token != "" { - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Discord token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + discordConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -673,11 +659,11 @@ func (s *NotificationService) sendTelegramNotification(ctx context.Context, envi // Decrypt bot token if encrypted if telegramConfig.BotToken != "" { - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { - telegramConfig.BotToken = decrypted - } else { - slog.Warn("Failed to decrypt Telegram bot token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + telegramConfig.BotToken = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -726,11 +712,11 @@ func (s *NotificationService) sendEmailNotification(ctx context.Context, environ } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderEmailTemplate(environmentName, imageRef, updateInfo) @@ -810,11 +796,11 @@ func (s *NotificationService) sendDiscordContainerUpdateNotification(ctx context // Decrypt token if encrypted if discordConfig.Token != "" { - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Discord token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + discordConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -852,11 +838,11 @@ func (s *NotificationService) sendTelegramContainerUpdateNotification(ctx contex // Decrypt bot token if encrypted if telegramConfig.BotToken != "" { - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { - telegramConfig.BotToken = decrypted - } else { - slog.Warn("Failed to decrypt Telegram bot token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + telegramConfig.BotToken = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -907,11 +893,11 @@ func (s *NotificationService) sendEmailContainerUpdateNotification(ctx context.C } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderContainerUpdateEmailTemplate(environmentName, containerName, imageRef, oldDigest, newDigest) @@ -1220,9 +1206,11 @@ func (s *NotificationService) sendTestEmail(ctx context.Context, environmentName } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderTestEmailTemplate(environmentName) @@ -1335,11 +1323,6 @@ func (s *NotificationService) sendBatchImageUpdateNotificationForTargetInternal( return nil } - // Send to Apprise if enabled - if appriseErr := s.appriseService.SendBatchImageUpdateNotification(ctx, target.EnvironmentName, updatesWithChanges); appriseErr != nil { - slog.WarnContext(ctx, "Failed to send Apprise notification", "error", appriseErr) - } - settings, err := s.GetAllSettings(ctx) if err != nil { return fmt.Errorf("failed to get notification settings: %w", err) @@ -1420,8 +1403,12 @@ func (s *NotificationService) sendBatchDiscordNotification(ctx context.Context, return fmt.Errorf("failed to unmarshal discord config: %w", err) } - // Decrypt token if encrypted - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { + // Decrypt token + if discordConfig.Token != "" { + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) + } discordConfig.Token = decrypted } @@ -1448,8 +1435,12 @@ func (s *NotificationService) sendBatchTelegramNotification(ctx context.Context, return fmt.Errorf("failed to unmarshal telegram config: %w", err) } - // Decrypt bot token if encrypted - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { + // Decrypt bot token + if telegramConfig.BotToken != "" { + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) + } telegramConfig.BotToken = decrypted } @@ -1498,11 +1489,11 @@ func (s *NotificationService) sendBatchEmailNotification(ctx context.Context, en } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderBatchEmailTemplate(environmentName, updates) @@ -1610,18 +1601,18 @@ func (s *NotificationService) sendSignalNotification(ctx context.Context, enviro // Decrypt sensitive fields if encrypted if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Signal password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Signal token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -1673,18 +1664,18 @@ func (s *NotificationService) sendSignalContainerUpdateNotification(ctx context. // Decrypt sensitive fields if encrypted if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Signal password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Signal token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1725,14 +1716,18 @@ func (s *NotificationService) sendBatchSignalNotification(ctx context.Context, e // Decrypt sensitive fields if encrypted if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1764,11 +1759,11 @@ func (s *NotificationService) sendSlackNotification(ctx context.Context, environ // Decrypt token if encrypted if slackConfig.Token != "" { - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Slack token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(slackConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + slackConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -1801,11 +1796,11 @@ func (s *NotificationService) sendSlackContainerUpdateNotification(ctx context.C // Decrypt token if encrypted if slackConfig.Token != "" { - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Slack token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(slackConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + slackConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1834,8 +1829,12 @@ func (s *NotificationService) sendBatchSlackNotification(ctx context.Context, en return fmt.Errorf("failed to unmarshal slack config: %w", err) } - // Decrypt token if encrypted - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { + // Decrypt token + if slackConfig.Token != "" { + decrypted, err := crypto.Decrypt(slackConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) + } slackConfig.Token = decrypted } @@ -1868,11 +1867,11 @@ func (s *NotificationService) sendNtfyNotification(ctx context.Context, environm // Decrypt password if encrypted if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Ntfy password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(ntfyConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + ntfyConfig.Password = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -1905,11 +1904,11 @@ func (s *NotificationService) sendNtfyContainerUpdateNotification(ctx context.Co // Decrypt password if encrypted if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Ntfy password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(ntfyConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + ntfyConfig.Password = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1940,9 +1939,11 @@ func (s *NotificationService) sendBatchNtfyNotification(ctx context.Context, env // Decrypt password if encrypted if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted + decrypted, err := crypto.Decrypt(ntfyConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + ntfyConfig.Password = decrypted } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1976,11 +1977,11 @@ func (s *NotificationService) sendPushoverNotification(ctx context.Context, envi } if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Pushover token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(pushoverConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + pushoverConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -2015,11 +2016,11 @@ func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx contex } if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Pushover token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(pushoverConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + pushoverConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2049,9 +2050,11 @@ func (s *NotificationService) sendBatchPushoverNotification(ctx context.Context, } if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted + decrypted, err := crypto.Decrypt(pushoverConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + pushoverConfig.Token = decrypted } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2193,11 +2196,11 @@ func (s *NotificationService) sendEmailVulnerabilityNotification(ctx context.Con } } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderVulnerabilitySummaryEmailTemplate(environmentName, payload) if err != nil { @@ -2223,11 +2226,11 @@ func (s *NotificationService) sendDiscordVulnerabilityNotification(ctx context.C return fmt.Errorf("discord webhook ID or token not configured") } if discordConfig.Token != "" { - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Discord token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + discordConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatMarkdown, @@ -2260,11 +2263,11 @@ func (s *NotificationService) sendTelegramVulnerabilityNotification(ctx context. return fmt.Errorf("no telegram chat IDs configured") } if telegramConfig.BotToken != "" { - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { - telegramConfig.BotToken = decrypted - } else { - slog.Warn("Failed to decrypt Telegram bot token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + telegramConfig.BotToken = decrypted } if telegramConfig.ParseMode == "" { telegramConfig.ParseMode = "HTML" @@ -2297,14 +2300,18 @@ func (s *NotificationService) sendSignalVulnerabilityNotification(ctx context.Co return fmt.Errorf("signal not fully configured") } if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2334,11 +2341,11 @@ func (s *NotificationService) sendSlackVulnerabilityNotification(ctx context.Con return fmt.Errorf("slack token not configured") } if slackConfig.Token != "" { - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Slack token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(slackConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + slackConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatSlack, @@ -2368,9 +2375,11 @@ func (s *NotificationService) sendNtfyVulnerabilityNotification(ctx context.Cont return fmt.Errorf("ntfy topic is required") } if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted + decrypted, err := crypto.Decrypt(ntfyConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + ntfyConfig.Password = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2400,9 +2409,11 @@ func (s *NotificationService) sendPushoverVulnerabilityNotification(ctx context. return fmt.Errorf("pushover token or user not configured") } if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted + decrypted, err := crypto.Decrypt(pushoverConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + pushoverConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2432,9 +2443,11 @@ func (s *NotificationService) sendGotifyVulnerabilityNotification(ctx context.Co return fmt.Errorf("failed to unmarshal Gotify config: %w", err) } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2464,9 +2477,11 @@ func (s *NotificationService) sendMatrixVulnerabilityNotification(ctx context.Co return fmt.Errorf("failed to unmarshal Matrix config: %w", err) } if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted + decrypted, err := crypto.Decrypt(matrixConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + matrixConfig.Password = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2550,11 +2565,11 @@ func (s *NotificationService) sendGotifyNotification(ctx context.Context, enviro } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -2582,11 +2597,11 @@ func (s *NotificationService) sendGotifyContainerUpdateNotification(ctx context. } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2616,9 +2631,11 @@ func (s *NotificationService) sendBatchGotifyNotification(ctx context.Context, e } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2645,9 +2662,11 @@ func (s *NotificationService) sendMatrixNotification(ctx context.Context, enviro } if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted + decrypted, err := crypto.Decrypt(matrixConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + matrixConfig.Password = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -2675,9 +2694,11 @@ func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context. } if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted + decrypted, err := crypto.Decrypt(matrixConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + matrixConfig.Password = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2707,9 +2728,11 @@ func (s *NotificationService) sendBatchMatrixNotification(ctx context.Context, e } if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted + decrypted, err := crypto.Decrypt(matrixConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + matrixConfig.Password = decrypted } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2725,103 +2748,6 @@ func (s *NotificationService) sendBatchMatrixNotification(ctx context.Context, e return nil } -// MigrateDiscordWebhookUrlToFields migrates legacy Discord webhookUrl to separate webhookId and token fields. -// This should be called during bootstrap to ensure existing Discord configurations are preserved. -func (s *NotificationService) MigrateDiscordWebhookUrlToFields(ctx context.Context) error { - var setting models.NotificationSettings - err := s.db.WithContext(ctx).Where("provider = ?", models.NotificationProviderDiscord).First(&setting).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // No Discord config exists, nothing to migrate - return nil - } - return fmt.Errorf("failed to query Discord settings: %w", err) - } - - var discordConfig models.DiscordConfig - configBytes, err := json.Marshal(setting.Config) - if err != nil { - return fmt.Errorf("failed to marshal Discord config: %w", err) - } - if err := json.Unmarshal(configBytes, &discordConfig); err != nil { - slog.WarnContext(ctx, "Failed to parse Discord config for migration", "error", err) - return nil - } - - // Check if already migrated (has webhookId and token) - if discordConfig.WebhookID != "" && discordConfig.Token != "" { - slog.DebugContext(ctx, "Discord config already migrated, skipping") - return nil - } - - // Check for legacy webhookUrl field - var legacyConfig struct { - WebhookUrl string `json:"webhookUrl"` - Username string `json:"username,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` - Events map[models.NotificationEventType]bool `json:"events,omitempty"` - } - if err := json.Unmarshal(configBytes, &legacyConfig); err != nil { - slog.WarnContext(ctx, "Failed to parse legacy Discord config structure", "error", err) - return nil - } - - if legacyConfig.WebhookUrl == "" { - slog.DebugContext(ctx, "No legacy webhookUrl to migrate") - return nil - } - - // Parse webhook URL: https://discord.com/api/webhooks/{id}/{token} - parts := strings.Split(legacyConfig.WebhookUrl, "/webhooks/") - if len(parts) != 2 { - slog.WarnContext(ctx, "Invalid Discord webhook URL format, skipping migration", "url", legacyConfig.WebhookUrl) - return nil - } - - webhookParts := strings.Split(parts[1], "/") - if len(webhookParts) != 2 { - slog.WarnContext(ctx, "Invalid Discord webhook URL format, skipping migration", "url", legacyConfig.WebhookUrl) - return nil - } - - webhookID := webhookParts[0] - token := webhookParts[1] - - slog.InfoContext(ctx, "Migrating legacy Discord webhookUrl to webhookId and token") - - // Encrypt token before storing - encryptedToken, err := crypto.Encrypt(token) - if err != nil { - return fmt.Errorf("failed to encrypt Discord token: %w", err) - } - - // Update with new structure - newConfig := models.DiscordConfig{ - WebhookID: webhookID, - Token: encryptedToken, - Username: legacyConfig.Username, - AvatarURL: legacyConfig.AvatarURL, - Events: legacyConfig.Events, - } - - var configMap models.JSON - newConfigBytes, err := json.Marshal(newConfig) - if err != nil { - return fmt.Errorf("failed to marshal new Discord config: %w", err) - } - if err = json.Unmarshal(newConfigBytes, &configMap); err != nil { - return fmt.Errorf("failed to unmarshal new Discord config to JSON: %w", err) - } - - setting.Config = configMap - if err = s.db.WithContext(ctx).Save(&setting).Error; err != nil { - return fmt.Errorf("failed to save migrated Discord config: %w", err) - } - - slog.InfoContext(ctx, "Successfully migrated Discord config") - return nil -} - func (s *NotificationService) SendPruneReportNotification(ctx context.Context, result *system.PruneAllResult) error { hasChanges := pruneResultHasChangesInternal(result) hasErrors := result != nil && len(result.Errors) > 0 @@ -2956,7 +2882,9 @@ func (s *NotificationService) sendDiscordPruneNotification(ctx context.Context, return fmt.Errorf("discord webhook ID or token not configured") } - s.decryptDiscordTokenInternal(&discordConfig) + if err := s.decryptDiscordTokenInternal(&discordConfig); err != nil { + return err + } message := notifications.BuildPruneReportNotificationMessage(notifications.MessageFormatMarkdown, environmentName, result) @@ -2977,7 +2905,9 @@ func (s *NotificationService) sendTelegramPruneNotification(ctx context.Context, return fmt.Errorf("telegram bot token or chat IDs not configured") } - s.decryptTelegramTokenInternal(&telegramConfig) + if err := s.decryptTelegramTokenInternal(&telegramConfig); err != nil { + return err + } message := notifications.BuildPruneReportNotificationMessage(notifications.MessageFormatHTML, environmentName, result) @@ -3002,7 +2932,9 @@ func (s *NotificationService) sendEmailPruneNotification(ctx context.Context, en return err } - s.decryptEmailPasswordInternal(&emailConfig) + if err := s.decryptEmailPasswordInternal(&emailConfig); err != nil { + return err + } htmlBody, _, err := s.renderPruneReportEmailTemplate(environmentName, result) if err != nil { @@ -3089,11 +3021,11 @@ func (s *NotificationService) sendGotifyPruneNotification(ctx context.Context, e return err } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } message := notifications.BuildPruneReportNotificationMessage(notifications.MessageFormatPlain, environmentName, result) @@ -3220,7 +3152,9 @@ func (s *NotificationService) sendDiscordAutoHealNotification(ctx context.Contex if discordConfig.WebhookID == "" || discordConfig.Token == "" { return fmt.Errorf("discord webhook ID or token not configured") } - s.decryptDiscordTokenInternal(&discordConfig) + if err := s.decryptDiscordTokenInternal(&discordConfig); err != nil { + return err + } message := notifications.BuildAutoHealNotificationMessage(notifications.MessageFormatMarkdown, environmentName, containerName) return notifications.SendDiscord(ctx, discordConfig, message) } @@ -3233,7 +3167,9 @@ func (s *NotificationService) sendEmailAutoHealNotification(ctx context.Context, if err := s.validateEmailConfigInternal(&emailConfig); err != nil { return err } - s.decryptEmailPasswordInternal(&emailConfig) + if err := s.decryptEmailPasswordInternal(&emailConfig); err != nil { + return err + } subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Auto Heal: Container '%s' Restarted", containerName)) body := fmt.Sprintf( "

Environment: %s

Container: %s

Automatically restarted because it was unhealthy.

", @@ -3251,7 +3187,9 @@ func (s *NotificationService) sendTelegramAutoHealNotification(ctx context.Conte if telegramConfig.BotToken == "" || len(telegramConfig.ChatIDs) == 0 { return fmt.Errorf("telegram bot token or chat IDs not configured") } - s.decryptTelegramTokenInternal(&telegramConfig) + if err := s.decryptTelegramTokenInternal(&telegramConfig); err != nil { + return err + } if telegramConfig.ParseMode == "" { telegramConfig.ParseMode = "HTML" } @@ -3304,11 +3242,11 @@ func (s *NotificationService) sendGotifyAutoHealNotification(ctx context.Context return err } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } if gotifyConfig.Title == "" { gotifyConfig.Title = notifications.BuildEmailSubject(environmentName, "Auto Heal") @@ -3358,28 +3296,37 @@ func (s *NotificationService) validateEmailConfigInternal(config *models.EmailCo return nil } -func (s *NotificationService) decryptDiscordTokenInternal(config *models.DiscordConfig) { +func (s *NotificationService) decryptDiscordTokenInternal(config *models.DiscordConfig) error { if config.Token != "" { - if decrypted, err := crypto.Decrypt(config.Token); err == nil { - config.Token = decrypted + decrypted, err := crypto.Decrypt(config.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + config.Token = decrypted } + return nil } -func (s *NotificationService) decryptTelegramTokenInternal(config *models.TelegramConfig) { +func (s *NotificationService) decryptTelegramTokenInternal(config *models.TelegramConfig) error { if config.BotToken != "" { - if decrypted, err := crypto.Decrypt(config.BotToken); err == nil { - config.BotToken = decrypted + decrypted, err := crypto.Decrypt(config.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + config.BotToken = decrypted } + return nil } -func (s *NotificationService) decryptEmailPasswordInternal(config *models.EmailConfig) { +func (s *NotificationService) decryptEmailPasswordInternal(config *models.EmailConfig) error { if config.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(config.SMTPPassword); err == nil { - config.SMTPPassword = decrypted + decrypted, err := crypto.Decrypt(config.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + config.SMTPPassword = decrypted } + return nil } func (s *NotificationService) renderTemplatesInternal(name string, data any) (string, string, error) { diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index ed674855b6..203c7bfb12 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -31,7 +31,7 @@ func setupNotificationTestDB(t *testing.T) *database.DB { t.Helper() db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{}, &models.AppriseSettings{})) + require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{})) // Initialize crypto for tests (requires 32+ byte key) testCfg := &config.Config{ @@ -84,263 +84,6 @@ func captureNotificationServiceLogsInternal(t *testing.T) *bytes.Buffer { return &buf } -func TestNotificationService_MigrateDiscordWebhookUrlToFields(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create legacy Discord config with webhookUrl - legacyConfig := map[string]any{ - "webhookUrl": "https://discord.com/api/webhooks/123456789/abcdef123456", - "username": "Arcane Bot", - "avatarUrl": "https://example.com/avatar.png", - "events": map[string]bool{ - "image_update": true, - "container_update": false, - }, - } - - configBytes, err := json.Marshal(legacyConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Run migration - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify migration results - var migratedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&migratedSetting).Error) - - var discordConfig models.DiscordConfig - configBytes, err = json.Marshal(migratedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &discordConfig)) - - // Verify webhookId and token were extracted - require.Equal(t, "123456789", discordConfig.WebhookID) - require.NotEmpty(t, discordConfig.Token) - - // Verify token is encrypted and can be decrypted - decryptedToken, err := crypto.Decrypt(discordConfig.Token) - require.NoError(t, err) - require.Equal(t, "abcdef123456", decryptedToken) - - // Verify other fields were preserved - require.Equal(t, "Arcane Bot", discordConfig.Username) - require.Equal(t, "https://example.com/avatar.png", discordConfig.AvatarURL) - require.True(t, discordConfig.Events[models.NotificationEventImageUpdate]) - require.False(t, discordConfig.Events[models.NotificationEventContainerUpdate]) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_SkipsIfAlreadyMigrated(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create already-migrated config with webhookId and token - encryptedToken, err := crypto.Encrypt("already-migrated-token") - require.NoError(t, err) - - migratedConfig := models.DiscordConfig{ - WebhookID: "999999999", - Token: encryptedToken, - Username: "Already Migrated", - } - - configBytes, err := json.Marshal(migratedConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Run migration - should skip - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify config was NOT changed - var unchangedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&unchangedSetting).Error) - - var discordConfig models.DiscordConfig - configBytes, err = json.Marshal(unchangedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &discordConfig)) - - require.Equal(t, "999999999", discordConfig.WebhookID) - require.Equal(t, encryptedToken, discordConfig.Token) - require.Equal(t, "Already Migrated", discordConfig.Username) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_NoDiscordConfig(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // No Discord config exists - migration should not error - err := svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify no settings were created - var count int64 - require.NoError(t, db.Model(&models.NotificationSettings{}).Count(&count).Error) - require.Equal(t, int64(0), count) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_InvalidWebhookUrl(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - testCases := []struct { - name string - webhookUrl string - }{ - { - name: "missing webhooks path", - webhookUrl: "https://discord.com/api/other/123456789/abcdef", - }, - { - name: "incomplete webhook path", - webhookUrl: "https://discord.com/api/webhooks/123456789", - }, - { - name: "empty webhook url", - webhookUrl: "", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Clean up before each sub-test - db.Exec("DELETE FROM notification_settings") - - legacyConfig := map[string]any{ - "webhookUrl": tc.webhookUrl, - } - - configBytes, err := json.Marshal(legacyConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Migration should not error but should skip invalid URLs - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify config was not changed - var unchangedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&unchangedSetting).Error) - - var resultConfig map[string]any - configBytes, err = json.Marshal(unchangedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &resultConfig)) - - // Should still have webhookUrl (not migrated) - if tc.webhookUrl != "" { - require.Equal(t, tc.webhookUrl, resultConfig["webhookUrl"]) - } - }) - } -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_EmptyConfig(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create Discord setting with empty config - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: false, - Config: models.JSON{}, - } - require.NoError(t, db.Create(&setting).Error) - - // Migration should not error - err := svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify config remains empty - var unchangedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&unchangedSetting).Error) - require.Empty(t, unchangedSetting.Config) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_PreservesAllFields(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create legacy config with all optional fields - legacyConfig := map[string]any{ - "webhookUrl": "https://discord.com/api/webhooks/111222333/token444555", - "username": "Custom Bot Name", - "avatarUrl": "https://cdn.example.com/bot-avatar.jpg", - "events": map[string]bool{ - "image_update": false, - "container_update": true, - }, - } - - configBytes, err := json.Marshal(legacyConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Run migration - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify all fields were preserved - var migratedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&migratedSetting).Error) - - var discordConfig models.DiscordConfig - configBytes, err = json.Marshal(migratedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &discordConfig)) - - require.Equal(t, "111222333", discordConfig.WebhookID) - require.NotEmpty(t, discordConfig.Token) - - decryptedToken, err := crypto.Decrypt(discordConfig.Token) - require.NoError(t, err) - require.Equal(t, "token444555", decryptedToken) - - require.Equal(t, "Custom Bot Name", discordConfig.Username) - require.Equal(t, "https://cdn.example.com/bot-avatar.jpg", discordConfig.AvatarURL) - require.False(t, discordConfig.Events[models.NotificationEventImageUpdate]) - require.True(t, discordConfig.Events[models.NotificationEventContainerUpdate]) -} - func TestNotificationService_ResolveNotificationTargetInternal_UsesEnvironmentRecordAndFallback(t *testing.T) { ctx := context.Background() db, _, svc := setupNotificationTestServiceInternal(t) diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index cc0a3b1b5e..906db6cf0c 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "log/slog" @@ -122,15 +121,9 @@ func DefaultSettingsConfig() *models.Settings { EventCleanupInterval: models.SettingVariable{Value: "0 0 */6 * * *"}, ExpiredSessionsCleanupInterval: models.SettingVariable{Value: "0 0 0 * * *"}, AutoInjectEnv: models.SettingVariable{Value: "false"}, - PruneMode: models.SettingVariable{Value: "dangling"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. DefaultDeployPullPolicy: models.SettingVariable{Value: "missing"}, ScheduledPruneEnabled: models.SettingVariable{Value: "false"}, ScheduledPruneInterval: models.SettingVariable{Value: "0 0 0 * * *"}, - ScheduledPruneContainers: models.SettingVariable{Value: "true"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneImages: models.SettingVariable{Value: "true"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneVolumes: models.SettingVariable{Value: "false"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneNetworks: models.SettingVariable{Value: "true"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneBuildCache: models.SettingVariable{Value: "false"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. PruneContainerMode: models.SettingVariable{Value: "stopped"}, PruneContainerUntil: models.SettingVariable{Value: ""}, PruneImageMode: models.SettingVariable{Value: "dangling"}, @@ -165,37 +158,35 @@ func DefaultSettingsConfig() *models.Settings { TrivyCpuLimit: models.SettingVariable{Value: "1"}, TrivyMemoryLimitMb: models.SettingVariable{Value: "0"}, TrivyConcurrentScanContainers: models.SettingVariable{Value: "1"}, - // AuthOidcConfig DEPRECATED will be removed in a future release - AuthOidcConfig: models.SettingVariable{Value: "{}"}, - OidcEnabled: models.SettingVariable{Value: "false"}, - OidcClientId: models.SettingVariable{Value: ""}, - OidcClientSecret: models.SettingVariable{Value: ""}, - OidcIssuerUrl: models.SettingVariable{Value: ""}, - OidcAuthorizationEndpoint: models.SettingVariable{Value: ""}, - OidcTokenEndpoint: models.SettingVariable{Value: ""}, - OidcUserinfoEndpoint: models.SettingVariable{Value: ""}, - OidcJwksEndpoint: models.SettingVariable{Value: ""}, - OidcScopes: models.SettingVariable{Value: "openid email profile"}, - OidcAdminClaim: models.SettingVariable{Value: ""}, - OidcAdminValue: models.SettingVariable{Value: ""}, - OidcSkipTlsVerify: models.SettingVariable{Value: "false"}, - OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"}, - OidcMergeAccounts: models.SettingVariable{Value: "false"}, - OidcProviderName: models.SettingVariable{Value: ""}, - OidcProviderLogoUrl: models.SettingVariable{Value: ""}, - OidcMobileRedirectUris: models.SettingVariable{Value: "arcane-mobile://oidc-callback"}, - MobileNavigationMode: models.SettingVariable{Value: "floating"}, - MobileNavigationShowLabels: models.SettingVariable{Value: "true"}, - SidebarHoverExpansion: models.SettingVariable{Value: "true"}, - KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"}, - ApplicationTheme: models.SettingVariable{Value: "default"}, - AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"}, - OledMode: models.SettingVariable{Value: "false"}, - MaxImageUploadSize: models.SettingVariable{Value: "500"}, - GitSyncMaxFiles: models.SettingVariable{Value: "500"}, - GitSyncMaxTotalSizeMb: models.SettingVariable{Value: "50"}, - GitSyncMaxBinarySizeMb: models.SettingVariable{Value: "10"}, - EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"}, + OidcEnabled: models.SettingVariable{Value: "false"}, + OidcClientId: models.SettingVariable{Value: ""}, + OidcClientSecret: models.SettingVariable{Value: ""}, + OidcIssuerUrl: models.SettingVariable{Value: ""}, + OidcAuthorizationEndpoint: models.SettingVariable{Value: ""}, + OidcTokenEndpoint: models.SettingVariable{Value: ""}, + OidcUserinfoEndpoint: models.SettingVariable{Value: ""}, + OidcJwksEndpoint: models.SettingVariable{Value: ""}, + OidcScopes: models.SettingVariable{Value: "openid email profile"}, + OidcAdminClaim: models.SettingVariable{Value: ""}, + OidcAdminValue: models.SettingVariable{Value: ""}, + OidcSkipTlsVerify: models.SettingVariable{Value: "false"}, + OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"}, + OidcMergeAccounts: models.SettingVariable{Value: "false"}, + OidcProviderName: models.SettingVariable{Value: ""}, + OidcProviderLogoUrl: models.SettingVariable{Value: ""}, + OidcMobileRedirectUris: models.SettingVariable{Value: "arcane-mobile://oidc-callback"}, + MobileNavigationMode: models.SettingVariable{Value: "floating"}, + MobileNavigationShowLabels: models.SettingVariable{Value: "true"}, + SidebarHoverExpansion: models.SettingVariable{Value: "true"}, + KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"}, + ApplicationTheme: models.SettingVariable{Value: "default"}, + AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"}, + OledMode: models.SettingVariable{Value: "false"}, + MaxImageUploadSize: models.SettingVariable{Value: "500"}, + GitSyncMaxFiles: models.SettingVariable{Value: "500"}, + GitSyncMaxTotalSizeMb: models.SettingVariable{Value: "50"}, + GitSyncMaxBinarySizeMb: models.SettingVariable{Value: "10"}, + EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"}, DockerAPITimeout: models.SettingVariable{Value: "30"}, DockerImagePullTimeout: models.SettingVariable{Value: "600"}, @@ -231,22 +222,6 @@ func (s *SettingsService) loadDatabaseSettingsInternal(ctx context.Context, db * return nil, fmt.Errorf("failed to load configuration from the database: %w", err) } - updated, err := s.ensureGranularPruneSettingsMigratedInternal(ctx, db, loaded) //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - if err != nil { - return nil, err - } - if updated { - loaded = nil - queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second) - defer queryCancel() - err = db. - WithContext(queryCtx). - Find(&loaded).Error - if err != nil { - return nil, fmt.Errorf("failed to reload configuration from the database after prune migration: %w", err) - } - } - for _, v := range loaded { err = dest.UpdateField(v.Key, v.Value, false) @@ -261,144 +236,6 @@ func (s *SettingsService) loadDatabaseSettingsInternal(ctx context.Context, db * return dest, nil } -// Deprecated: This migration path exists only to lift legacy prune settings into the granular model. -func (s *SettingsService) ensureGranularPruneSettingsMigratedInternal(ctx context.Context, db *database.DB, loaded []models.SettingVariable) (bool, error) { - loadedMap := make(map[string]string, len(loaded)) - for _, setting := range loaded { - loadedMap[setting.Key] = setting.Value - } - - missingKeys := []string{ - "pruneContainerMode", - "pruneContainerUntil", - "pruneImageMode", - "pruneImageUntil", - "pruneVolumeMode", - "pruneNetworkMode", - "pruneNetworkUntil", - "pruneBuildCacheMode", - "pruneBuildCacheUntil", - } - - needsMigration := false - for _, key := range missingKeys { - if _, ok := loadedMap[key]; !ok { - needsMigration = true - break - } - } - - if !needsMigration { - return false, nil - } - - pruneMode := loadedMap["dockerPruneMode"] - if pruneMode == "" { - pruneMode = "dangling" - } - - valuesToPersist := []models.SettingVariable{ - {Key: "pruneContainerMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyContainerPruneModeInternal(loadedMap["scheduledPruneContainers"]), "pruneContainerMode", "scheduledPruneContainerMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneContainerUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneContainerUntil", "scheduledPruneContainerUntil")}, - {Key: "pruneImageMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyImagePruneModeInternal(pruneMode, loadedMap["scheduledPruneImages"]), "pruneImageMode", "scheduledPruneImageMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneImageUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneImageUntil", "scheduledPruneImageUntil")}, - {Key: "pruneVolumeMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyVolumePruneModeInternal(pruneMode, loadedMap["scheduledPruneVolumes"]), "pruneVolumeMode", "scheduledPruneVolumeMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneNetworkMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyNetworkPruneModeInternal(loadedMap["scheduledPruneNetworks"]), "pruneNetworkMode", "scheduledPruneNetworkMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneNetworkUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneNetworkUntil", "scheduledPruneNetworkUntil")}, - {Key: "pruneBuildCacheMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyBuildCachePruneModeInternal(pruneMode, loadedMap["scheduledPruneBuildCache"]), "pruneBuildCacheMode", "scheduledPruneBuildCacheMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneBuildCacheUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneBuildCacheUntil", "scheduledPruneBuildCacheUntil")}, - } - - if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for _, value := range valuesToPersist { - if _, ok := loadedMap[value.Key]; ok { - continue - } - if err := tx.Save(&value).Error; err != nil { - return fmt.Errorf("failed to persist migrated prune setting %s: %w", value.Key, err) - } - } - return nil - }); err != nil { - return false, fmt.Errorf("failed to migrate granular prune settings: %w", err) - } - - slog.InfoContext(ctx, "migrated legacy prune settings to granular prune settings") - return true, nil -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyContainerPruneModeInternal(legacyValue string) string { - if isLegacyPruneEnabledInternal(legacyValue, true) { - return "stopped" - } - return "none" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyImagePruneModeInternal(pruneMode, legacyValue string) string { - if !isLegacyPruneEnabledInternal(legacyValue, true) { - return "none" - } - if pruneMode == "all" { - return "all" - } - return "dangling" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyVolumePruneModeInternal(pruneMode, legacyValue string) string { - if !isLegacyPruneEnabledInternal(legacyValue, false) { - return "none" - } - if pruneMode == "all" { - return "all" - } - return "anonymous" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyNetworkPruneModeInternal(legacyValue string) string { - if isLegacyPruneEnabledInternal(legacyValue, true) { - return "unused" - } - return "none" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyBuildCachePruneModeInternal(pruneMode, legacyValue string) string { - if !isLegacyPruneEnabledInternal(legacyValue, false) { - return "none" - } - if pruneMode == "all" { - return "all" - } - return "unused" -} - -func isLegacyPruneEnabledInternal(value string, defaultValue bool) bool { - if strings.TrimSpace(value) == "" { - return defaultValue - } - - parsed, err := strconv.ParseBool(value) - if err != nil { - return defaultValue - } - - return parsed -} - -func coalesceSettingValueInternal(loadedMap map[string]string, fallback string, keys ...string) string { - for _, key := range keys { - if value, ok := loadedMap[key]; ok { - return value - } - } - - return fallback -} - func (s *SettingsService) loadDatabaseConfigFromEnv(ctx context.Context, db *database.DB) (*models.Settings, error) { dest := s.getDefaultSettings() @@ -543,113 +380,6 @@ func (s *SettingsService) getEffectiveSettingsConfigInternal(ctx context.Context return settings } -// MigrateOidcConfigToFields migrates the legacy JSON authOidcConfig to individual fields, -// and renames legacy auth* keys to their new oidc* names. -// This should be called during bootstrap to ensure existing configurations are preserved. -func (s *SettingsService) MigrateOidcConfigToFields(ctx context.Context) error { - currentSettings, err := s.GetSettings(ctx) - if err != nil { - return fmt.Errorf("failed to get settings for OIDC migration: %w", err) - } - - // Migrate legacy key names (authOidcEnabled -> oidcEnabled, authOidcMergeAccounts -> oidcMergeAccounts) - if err := s.migrateOidcKeyNames(ctx); err != nil { - slog.WarnContext(ctx, "Failed to migrate OIDC key names", "error", err) - // Continue with JSON migration even if key rename fails - } - - // Check if migration is needed: if we have authOidcConfig but no oidcClientId - if currentSettings.AuthOidcConfig.Value == "" || currentSettings.AuthOidcConfig.Value == "{}" { - slog.DebugContext(ctx, "No OIDC config to migrate") - return nil - } - - // If individual fields are already populated, skip migration - if currentSettings.OidcClientId.Value != "" { - slog.DebugContext(ctx, "OIDC fields already populated, skipping migration") - return nil - } - - var oidcConfig models.OidcConfig - if err := json.Unmarshal([]byte(currentSettings.AuthOidcConfig.Value), &oidcConfig); err != nil { - slog.WarnContext(ctx, "Failed to parse legacy OIDC config for migration", "error", err) - return nil - } - - // Only migrate if there's actual data - if oidcConfig.ClientID == "" && oidcConfig.IssuerURL == "" { - slog.DebugContext(ctx, "Legacy OIDC config is empty, skipping migration") - return nil - } - - slog.InfoContext(ctx, "Migrating legacy OIDC config to individual fields") - - scopes := oidcConfig.Scopes - if scopes == "" { - scopes = "openid email profile" - } - - _, err = s.UpdateSettings(ctx, settings.Update{ - OidcClientId: new(oidcConfig.ClientID), - OidcClientSecret: new(oidcConfig.ClientSecret), - OidcIssuerUrl: new(oidcConfig.IssuerURL), - OidcScopes: new(scopes), - OidcAdminClaim: new(oidcConfig.AdminClaim), - OidcAdminValue: new(oidcConfig.AdminValue), - }) - if err != nil { - return fmt.Errorf("failed to migrate OIDC config: %w", err) - } - - slog.InfoContext(ctx, "Successfully migrated OIDC config to individual fields") - return nil -} - -// migrateOidcKeyNames renames legacy authOidc* keys to new oidc* keys in the database. -func (s *SettingsService) migrateOidcKeyNames(ctx context.Context) error { - keyMappings := map[string]string{ - "authOidcEnabled": "oidcEnabled", - "authOidcMergeAccounts": "oidcMergeAccounts", - "authOidcClientId": "oidcClientId", - "authOidcClientSecret": "oidcClientSecret", - "authOidcIssuerUrl": "oidcIssuerUrl", - "authOidcScopes": "oidcScopes", - "authOidcAdminClaim": "oidcAdminClaim", - "authOidcAdminValue": "oidcAdminValue", - } - - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for oldKey, newKey := range keyMappings { - // Check if old key exists - var oldSetting models.SettingVariable - if err := tx.Where("key = ?", oldKey).First(&oldSetting).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue // Old key doesn't exist, nothing to migrate - } - return fmt.Errorf("failed to check old key %s: %w", oldKey, err) - } - - // Check if new key already exists - var newSetting models.SettingVariable - if err := tx.Where("key = ?", newKey).First(&newSetting).Error; err == nil { - // New key already exists, delete the old one - if err := tx.Delete(&oldSetting).Error; err != nil { - return fmt.Errorf("failed to delete old key %s: %w", oldKey, err) - } - slog.DebugContext(ctx, "Deleted duplicate legacy key", "oldKey", oldKey, "newKey", newKey) - continue - } - - // Rename: update key from old to new - if err := tx.Model(&oldSetting).Update("key", newKey).Error; err != nil { - return fmt.Errorf("failed to rename key %s to %s: %w", oldKey, newKey, err) - } - slog.InfoContext(ctx, "Migrated OIDC setting key", "oldKey", oldKey, "newKey", newKey) - } - return nil - }) -} - func (s *SettingsService) UpdateSetting(ctx context.Context, key, value string) error { if err := s.updateSettingValueNoRefreshInternal(ctx, key, value); err != nil { return err @@ -798,12 +528,7 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa case "autoUpdate", "autoUpdateInterval": changedAutoUpdate = true case "scheduledPruneEnabled", - "scheduledPruneInterval", - "scheduledPruneContainers", - "scheduledPruneImages", - "scheduledPruneVolumes", - "scheduledPruneNetworks", - "scheduledPruneBuildCache": + "scheduledPruneInterval": changedScheduledPrune = true case "vulnerabilityScanEnabled", "vulnerabilityScanInterval", "trivyNetwork", "trivySecurityOpts", "trivyPrivileged", "trivyResourceLimitsEnabled", "trivyCpuLimit", "trivyMemoryLimitMb", "trivyConcurrentScanContainers": changedVulnerabilityScan = true @@ -849,38 +574,6 @@ func (s *SettingsService) persistSettings(ctx context.Context, values []models.S } func (s *SettingsService) handleOidcConfigUpdate(ctx context.Context, updates settings.Update) error { - // Handle legacy JSON config format (for backward compatibility during migration) - if updates.AuthOidcConfig != nil { - newCfgStr := *updates.AuthOidcConfig - var incoming models.OidcConfig - if err := json.Unmarshal([]byte(newCfgStr), &incoming); err != nil { - return fmt.Errorf("invalid authOidcConfig JSON: %w", err) - } - - current, err := s.GetSettings(ctx) - if err != nil { - return fmt.Errorf("failed to load current settings: %w", err) - } - - if current.AuthOidcConfig.Value != "" { - var existing models.OidcConfig - if err := json.Unmarshal([]byte(current.AuthOidcConfig.Value), &existing); err == nil { - if incoming.ClientSecret == "" { - incoming.ClientSecret = existing.ClientSecret - } - } - } - - mergedBytes, err := incoming.MarshalDocument() - if err != nil { - return fmt.Errorf("failed to marshal merged OIDC config: %w", err) - } - - if err := s.updateSettingValueNoRefreshInternal(ctx, "authOidcConfig", string(mergedBytes)); err != nil { - return fmt.Errorf("failed to update authOidcConfig: %w", err) - } - } - // Handle new individual field for client secret (sensitive field) if updates.OidcClientSecret != nil { secret := *updates.OidcClientSecret diff --git a/backend/internal/services/settings_service_test.go b/backend/internal/services/settings_service_test.go index 75f71fae3a..cb80a4d162 100644 --- a/backend/internal/services/settings_service_test.go +++ b/backend/internal/services/settings_service_test.go @@ -2,7 +2,6 @@ package services import ( "context" - "encoding/json" "path/filepath" "testing" @@ -129,80 +128,6 @@ func TestSettingsService_GetSettings_UsesCachedSnapshotWithoutDatabase(t *testin require.Equal(t, "http://cached", settings.BaseServerURL.Value) } -func TestSettingsService_LoadDatabaseSettings_MigratesLegacyPruneSettings(t *testing.T) { - ctx := context.Background() - t.Setenv("UI_CONFIGURATION_DISABLED", "false") - t.Setenv("AGENT_MODE", "false") - t.Setenv("EDGE_AGENT", "false") - - tests := []struct { - name string - legacy []models.SettingVariable - assertCfg func(t *testing.T, cfg *models.Settings) - }{ - { - name: "maps all prune mode to aggressive resource modes", - legacy: []models.SettingVariable{ - {Key: "dockerPruneMode", Value: "all"}, - {Key: "scheduledPruneContainers", Value: "true"}, - {Key: "scheduledPruneImages", Value: "true"}, - {Key: "scheduledPruneVolumes", Value: "true"}, - {Key: "scheduledPruneNetworks", Value: "true"}, - {Key: "scheduledPruneBuildCache", Value: "true"}, - }, - assertCfg: func(t *testing.T, cfg *models.Settings) { - require.Equal(t, "stopped", cfg.PruneContainerMode.Value) - require.Equal(t, "all", cfg.PruneImageMode.Value) - require.Equal(t, "all", cfg.PruneVolumeMode.Value) - require.Equal(t, "unused", cfg.PruneNetworkMode.Value) - require.Equal(t, "all", cfg.PruneBuildCacheMode.Value) - }, - }, - { - name: "uses dangling defaults when legacy booleans are missing", - legacy: []models.SettingVariable{ - {Key: "dockerPruneMode", Value: "dangling"}, - }, - assertCfg: func(t *testing.T, cfg *models.Settings) { - require.Equal(t, "stopped", cfg.PruneContainerMode.Value) - require.Equal(t, "dangling", cfg.PruneImageMode.Value) - require.Equal(t, "none", cfg.PruneVolumeMode.Value) - require.Equal(t, "unused", cfg.PruneNetworkMode.Value) - require.Equal(t, "none", cfg.PruneBuildCacheMode.Value) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db := setupSettingsTestDB(t) - require.NoError(t, db.DB.Create(&tt.legacy).Error) - - svc := &SettingsService{db: db} - cfg, err := svc.loadDatabaseSettingsInternal(ctx, db) - require.NoError(t, err) - - tt.assertCfg(t, cfg) - - for _, key := range []string{ - "pruneContainerMode", - "pruneContainerUntil", - "pruneImageMode", - "pruneImageUntil", - "pruneVolumeMode", - "pruneNetworkMode", - "pruneNetworkUntil", - "pruneBuildCacheMode", - "pruneBuildCacheUntil", - } { - var setting models.SettingVariable - err := db.DB.Where("key = ?", key).First(&setting).Error - require.NoErrorf(t, err, "expected migrated key %s to be persisted", key) - } - }) - } -} - func TestSettingsService_PruneUnknownSettings_RemovesStaleKeys(t *testing.T) { ctx := context.Background() db := setupSettingsTestDB(t) @@ -489,44 +414,6 @@ func TestSettingsService_EnsureEncryptionKey(t *testing.T) { require.Equal(t, k1, sv.Value) } -func TestSettingsService_UpdateSettings_MergeOidcSecret(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - - // Seed existing OIDC config with a secret - existing := models.OidcConfig{ - ClientID: "old", - ClientSecret: "keep-this", - IssuerURL: "https://issuer", - } - b, err := json.Marshal(existing) - require.NoError(t, err) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", string(b))) - - // Incoming update missing clientSecret should preserve existing one - incoming := models.OidcConfig{ - ClientID: "new", - IssuerURL: "https://issuer", - } - nb, err := json.Marshal(incoming) - require.NoError(t, err) - updates := settings.Update{ - AuthOidcConfig: new(string(nb)), - } - _, err = svc.UpdateSettings(ctx, updates) - require.NoError(t, err) - - var cfgVar models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "authOidcConfig").First(&cfgVar).Error) - - var merged models.OidcConfig - require.NoError(t, json.Unmarshal([]byte(cfgVar.Value), &merged)) - require.Equal(t, "new", merged.ClientID) - require.Equal(t, "keep-this", merged.ClientSecret) -} - func TestSettingsService_LoadDatabaseSettings_ReloadsChanges(t *testing.T) { ctx := context.Background() db := setupSettingsTestDB(t) @@ -833,183 +720,6 @@ func TestSettingsService_LoadDatabaseSettings_InternalKeys_EnvMode(t *testing.T) require.Equal(t, internalVal, cfg.InstanceID.Value) } -func TestSettingsService_MigrateOidcConfigToFields(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Seed legacy OIDC JSON config - legacyConfig := models.OidcConfig{ - ClientID: "legacy-client-id", - ClientSecret: "legacy-secret", - IssuerURL: "https://legacy-issuer.example", - Scopes: "openid email profile", - AdminClaim: "groups", - AdminValue: "admin", - } - b, err := json.Marshal(legacyConfig) - require.NoError(t, err) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", string(b))) - - // Run migration - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify individual fields were populated - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Equal(t, "legacy-client-id", clientId.Value) - - var clientSecret models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientSecret").First(&clientSecret).Error) - require.Equal(t, "legacy-secret", clientSecret.Value) - - var issuerUrl models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcIssuerUrl").First(&issuerUrl).Error) - require.Equal(t, "https://legacy-issuer.example", issuerUrl.Value) - - var scopes models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcScopes").First(&scopes).Error) - require.Equal(t, "openid email profile", scopes.Value) - - var adminClaim models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminClaim").First(&adminClaim).Error) - require.Equal(t, "groups", adminClaim.Value) - - var adminValue models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminValue").First(&adminValue).Error) - require.Equal(t, "admin", adminValue.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_SkipsIfAlreadyMigrated(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Pre-populate individual field - require.NoError(t, svc.UpdateSetting(ctx, "oidcClientId", "already-migrated")) - - // Seed legacy config too - legacyConfig := models.OidcConfig{ - ClientID: "old-id", - IssuerURL: "https://old-issuer.example", - } - b, err := json.Marshal(legacyConfig) - require.NoError(t, err) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", string(b))) - - // Run migration - should skip since individual field is populated - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify field was NOT overwritten - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Equal(t, "already-migrated", clientId.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_RealWorldJSON(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Test with real-world JSON format (as stored in database) - realWorldJSON := `{"clientId":"ab92b6cf-283d-4764-9308-92a9b9496bf1","clientSecret":"super-secret-value","issuerUrl":"https://id.ofkm.us","scopes":"openid email profile groups","adminClaim":"groups","adminValue":"_arcane_admins"}` - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", realWorldJSON)) - - // Run migration - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify all individual fields were populated correctly - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Equal(t, "ab92b6cf-283d-4764-9308-92a9b9496bf1", clientId.Value) - - var clientSecret models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientSecret").First(&clientSecret).Error) - require.Equal(t, "super-secret-value", clientSecret.Value) - - var issuerUrl models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcIssuerUrl").First(&issuerUrl).Error) - require.Equal(t, "https://id.ofkm.us", issuerUrl.Value) - - var scopes models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcScopes").First(&scopes).Error) - require.Equal(t, "openid email profile groups", scopes.Value) - - var adminClaim models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminClaim").First(&adminClaim).Error) - require.Equal(t, "groups", adminClaim.Value) - - var adminValue models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminValue").First(&adminValue).Error) - require.Equal(t, "_arcane_admins", adminValue.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_EmptyConfig(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Empty config should not cause errors - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", "{}")) - - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify fields remain empty - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Empty(t, clientId.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_InvalidJSON(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Invalid JSON should not cause errors (gracefully handled) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", "not valid json")) - - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) // Should not return error, just skip - - // Verify fields remain empty - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Empty(t, clientId.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_DefaultScopes(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Config without scopes should get default scopes - configWithoutScopes := `{"clientId":"test-client","issuerUrl":"https://test.example"}` - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", configWithoutScopes)) - - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - var scopes models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcScopes").First(&scopes).Error) - require.Equal(t, "openid email profile", scopes.Value) -} - func TestSettingsService_NormalizeProjectsDirectory_ConvertsRelativeToAbsolute(t *testing.T) { ctx := context.Background() db := setupSettingsTestDB(t) diff --git a/backend/pkg/dockerutil/jsonstream.go b/backend/pkg/dockerutil/jsonstream.go index 7612273ec4..482850870e 100644 --- a/backend/pkg/dockerutil/jsonstream.go +++ b/backend/pkg/dockerutil/jsonstream.go @@ -3,9 +3,7 @@ package docker import ( "bufio" "encoding/json" - "errors" "io" - "strings" "github.com/moby/moby/api/types/jsonstream" ) @@ -32,14 +30,6 @@ func ConsumeJSONMessageStream(reader io.Reader, lineHandler func([]byte) error) if msg.Error != nil { return msg.Error } - - // Some daemons include an additional top-level "error" string. - var legacy struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(line, &legacy); err == nil && strings.TrimSpace(legacy.Error) != "" { - return errors.New(strings.TrimSpace(legacy.Error)) - } } if err := scanner.Err(); err != nil { diff --git a/backend/pkg/dockerutil/jsonstream_test.go b/backend/pkg/dockerutil/jsonstream_test.go index 6766fd9d84..783a6a5521 100644 --- a/backend/pkg/dockerutil/jsonstream_test.go +++ b/backend/pkg/dockerutil/jsonstream_test.go @@ -30,11 +30,4 @@ func TestConsumeJSONMessageStream(t *testing.T) { } }) - t.Run("returns legacy top-level error string", func(t *testing.T) { - stream := strings.NewReader(`{"error":"manifest unknown"}` + "\n") - err := ConsumeJSONMessageStream(stream, nil) - if err == nil || !strings.Contains(err.Error(), "manifest unknown") { - t.Fatalf("expected manifest unknown error, got %v", err) - } - }) } diff --git a/backend/pkg/libarcane/edge/client_grpc_test.go b/backend/pkg/libarcane/edge/client_grpc_test.go index 94c4fa671e..68a5464411 100644 --- a/backend/pkg/libarcane/edge/client_grpc_test.go +++ b/backend/pkg/libarcane/edge/client_grpc_test.go @@ -574,97 +574,7 @@ func TestTunnelClient_connectAndServe_WebSocketConfigFallsBackToWebSocket(t *tes } } -func TestTunnelClient_connectAndServe_AutoTransportFallsBackToWebSocketWhenGRPCUnavailable(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - wsConnectedCh := make(chan struct{}, 1) - managerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/tunnel/connect" { - http.NotFound(w, r) - return - } - - upgrader := websocket.Upgrader{} - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer func() { _ = conn.Close() }() - - select { - case wsConnectedCh <- struct{}{}: - default: - } - - time.Sleep(100 * time.Millisecond) - })) - defer managerServer.Close() - - cfg := &Config{ - EdgeTransport: EdgeTransportAuto, - ManagerApiUrl: managerServer.URL, - AgentToken: "valid-token", - } - - client := NewTunnelClient(cfg, http.NotFoundHandler()) - err := client.connectAndServe(ctx) - require.Error(t, err) - - select { - case <-wsConnectedCh: - case <-time.After(2 * time.Second): - t.Fatal("expected auto transport to fall back to websocket") - } -} - -func TestTunnelClient_connectAndServe_AutoTransportDerivesWebSocketFallbackURL(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - wsConnectedCh := make(chan struct{}, 1) - managerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/tunnel/connect" { - http.NotFound(w, r) - return - } - - upgrader := websocket.Upgrader{} - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer func() { _ = conn.Close() }() - - select { - case wsConnectedCh <- struct{}{}: - default: - } - - time.Sleep(100 * time.Millisecond) - })) - defer managerServer.Close() - - cfg := &Config{ - EdgeTransport: EdgeTransportAuto, - ManagerApiUrl: managerServer.URL, - AgentToken: "valid-token", - } - - client := NewTunnelClient(cfg, http.NotFoundHandler()) - client.managerURL = "" - - err := client.connectAndServe(ctx) - require.Error(t, err) - - select { - case <-wsConnectedCh: - case <-time.After(2 * time.Second): - t.Fatal("expected auto transport to derive websocket fallback URL") - } -} - -func TestTunnelClient_connectAndServe_AutoTransportOpensGRPCWhenAvailable(t *testing.T) { +func TestTunnelClient_connectAndServe_OpensGRPCWhenAvailable(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() @@ -687,7 +597,7 @@ func TestTunnelClient_connectAndServe_AutoTransportOpensGRPCWhenAvailable(t *tes defer stopManager() client := NewTunnelClient(&Config{ - EdgeTransport: EdgeTransportAuto, + EdgeTransport: EdgeTransportGRPC, ManagerApiUrl: managerURL, AgentToken: "valid-token", }, http.NotFoundHandler()) diff --git a/backend/pkg/libarcane/edge/commands.go b/backend/pkg/libarcane/edge/commands.go index de82f682a9..54a83df86c 100644 --- a/backend/pkg/libarcane/edge/commands.go +++ b/backend/pkg/libarcane/edge/commands.go @@ -144,9 +144,6 @@ var commandRoutes = []commandRoute{ {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/settings", CommandName: "notification.settings.upsert"}, {Method: http.MethodDelete, PathPattern: "/api/environments/{id}/notifications/settings/{provider}", CommandName: "notification.settings.delete"}, {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/test/{provider}", CommandName: "notification.test"}, - {Method: http.MethodGet, PathPattern: "/api/environments/{id}/notifications/apprise", CommandName: "notification.apprise.get"}, - {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/apprise", CommandName: "notification.apprise.upsert"}, - {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/apprise/test", CommandName: "notification.apprise.test"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/dashboard", CommandName: "dashboard.snapshot"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/dashboard/action-items", CommandName: "dashboard.action_items"}, diff --git a/backend/pkg/libarcane/edge/registry.go b/backend/pkg/libarcane/edge/registry.go index 25cf9fefe7..62bfb6c05f 100644 --- a/backend/pkg/libarcane/edge/registry.go +++ b/backend/pkg/libarcane/edge/registry.go @@ -122,9 +122,8 @@ func (r *TunnelRegistry) RegisterSession(tunnel *AgentTunnel, staleAfter time.Du existingStale := staleAfter > 0 && time.Since(existing.GetLastHeartbeat()) > staleAfter sameAgentInstance := existing.AgentInstance != "" && tunnel.AgentInstance != "" && existing.AgentInstance == tunnel.AgentInstance - legacyReplace := existing.AgentInstance == "" || tunnel.AgentInstance == "" - if existing.Conn == nil || existing.Conn.IsClosed() || existingStale || sameAgentInstance || legacyReplace { + if existing.Conn == nil || existing.Conn.IsClosed() || existingStale || sameAgentInstance { if sameAgentInstance { drainPrevious = true } diff --git a/backend/pkg/libarcane/edge/tls.go b/backend/pkg/libarcane/edge/tls.go index 99cdd2a3aa..cecfa8f874 100644 --- a/backend/pkg/libarcane/edge/tls.go +++ b/backend/pkg/libarcane/edge/tls.go @@ -715,9 +715,6 @@ func ensureManagerCAInternal(ctx context.Context, assetsDir string) (string, str caCertPath := filepath.Join(assetsDir, generatedMTLSCACertFileName) caKeyPath := filepath.Join(assetsDir, generatedMTLSCAKeyFileName) if generatedCAReadyInternal(caCertPath, caKeyPath) { - if err := migratePlainCAKeyInternal(caKeyPath); err != nil { - return "", "", false, err - } return caCertPath, caKeyPath, false, nil } _ = os.Remove(caCertPath) @@ -763,25 +760,6 @@ func generatedCAReadyInternal(caCertPath string, caKeyPath string) bool { return true } -func migratePlainCAKeyInternal(caKeyPath string) error { - if isCAKeyEncryptedOnDiskInternal(caKeyPath) { - return nil - } - keyPEM, err := os.ReadFile(caKeyPath) - if err != nil { - return fmt.Errorf("failed to read legacy plain edge mTLS CA key for migration: %w", err) - } - block, _ := pem.Decode(keyPEM) - if block == nil { - return fmt.Errorf("failed to decode legacy plain edge mTLS CA key for migration") - } - if err := writeCAKeyFileInternal(caKeyPath, block.Bytes); err != nil { - return fmt.Errorf("failed to migrate edge mTLS CA key to encrypted format: %w", err) - } - slog.Info("migrated edge mTLS CA key to encrypted format", "path", caKeyPath) - return nil -} - func ensureClientCertificateInternal(ctx context.Context, assetsDir string, envID string, envName string, appURL string) (string, string, bool, error) { caCertPath, caKeyPath, _, err := ensureManagerCAInternal(ctx, assetsDir) if err != nil { @@ -1231,9 +1209,7 @@ const caKeyEncryptedPrefix = "ARCANE-ENC-V1:" var caKeyEncryptInternal = libcrypto.Encrypt // writeCAKeyFileInternal writes the edge CA private key to disk using envelope -// encryption via libcrypto. The on-disk format is self-describing so -// readCAKeyPEMInternal can transparently handle encrypted files and legacy -// plaintext files. +// encryption via libcrypto. func writeCAKeyFileInternal(path string, derBytes []byte) error { pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: derBytes}) if pemBytes == nil { @@ -1252,36 +1228,22 @@ func writeCAKeyFileInternal(path string, derBytes []byte) error { } // readCAKeyPEMInternal returns the plain PEM bytes of the edge CA private key, -// accepting either the legacy plain PEM file or a libcrypto-envelope-encrypted -// file written by writeCAKeyFileInternal. +// reading a libcrypto-envelope-encrypted file written by writeCAKeyFileInternal. func readCAKeyPEMInternal(path string) ([]byte, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read CA private key %s: %w", path, err) } trimmed := strings.TrimSpace(string(raw)) - if strings.HasPrefix(trimmed, caKeyEncryptedPrefix) { - ciphertext := strings.TrimPrefix(trimmed, caKeyEncryptedPrefix) - plaintext, err := libcrypto.Decrypt(ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to decrypt CA private key %s: %w", path, err) - } - return []byte(plaintext), nil + if !strings.HasPrefix(trimmed, caKeyEncryptedPrefix) { + return nil, fmt.Errorf("CA private key %s is not in the expected encrypted envelope format", path) } - return raw, nil -} - -// isCAKeyEncryptedOnDiskInternal reports whether the given CA key file is -// stored in the libcrypto envelope format. -func isCAKeyEncryptedOnDiskInternal(path string) bool { - f, err := os.Open(path) + ciphertext := strings.TrimPrefix(trimmed, caKeyEncryptedPrefix) + plaintext, err := libcrypto.Decrypt(ciphertext) if err != nil { - return false + return nil, fmt.Errorf("failed to decrypt CA private key %s: %w", path, err) } - defer func() { _ = f.Close() }() - head := make([]byte, len(caKeyEncryptedPrefix)) - n, _ := io.ReadFull(f, head) - return n == len(caKeyEncryptedPrefix) && string(head) == caKeyEncryptedPrefix + return []byte(plaintext), nil } // writeFileAtomicInternal writes data to path via a temp file + rename. diff --git a/backend/pkg/libarcane/edge/tls_test.go b/backend/pkg/libarcane/edge/tls_test.go index babe5ee413..626faddadf 100644 --- a/backend/pkg/libarcane/edge/tls_test.go +++ b/backend/pkg/libarcane/edge/tls_test.go @@ -488,9 +488,6 @@ func TestCAKey_EncryptedOnDiskWhenCryptoInitialized(t *testing.T) { require.FileExists(t, caCertPath) require.FileExists(t, caKeyPath) - require.True(t, isCAKeyEncryptedOnDiskInternal(caKeyPath), - "CA key file must be stored in the encrypted envelope format when libcrypto is initialized") - raw, err := os.ReadFile(caKeyPath) require.NoError(t, err) require.False(t, strings.Contains(string(raw), "BEGIN EC PRIVATE KEY"), @@ -509,49 +506,6 @@ func TestCAKey_EncryptedOnDiskWhenCryptoInitialized(t *testing.T) { require.FileExists(t, clientKeyPath) } -func TestCAKey_MigratesLegacyPlainFile(t *testing.T) { - initEdgeTestCrypto(t) - - assetsDir := t.TempDir() - _, caKeyPath, _, err := ensureManagerCAInternal(context.Background(), assetsDir) - require.NoError(t, err) - - pemBytes, err := readCAKeyPEMInternal(caKeyPath) - require.NoError(t, err) - require.NoError(t, os.WriteFile(caKeyPath, pemBytes, 0o600)) - require.False(t, isCAKeyEncryptedOnDiskInternal(caKeyPath)) - - _, caKeyPath2, _, err := ensureManagerCAInternal(context.Background(), assetsDir) - require.NoError(t, err) - require.Equal(t, caKeyPath, caKeyPath2) - require.True(t, isCAKeyEncryptedOnDiskInternal(caKeyPath), - "legacy plain CA key file must be migrated to the encrypted envelope format on next load") -} - -func TestCAKey_LegacyPlainMigrationFailureReturnsError(t *testing.T) { - initEdgeTestCrypto(t) - - assetsDir := t.TempDir() - _, caKeyPath, _, err := ensureManagerCAInternal(context.Background(), assetsDir) - require.NoError(t, err) - - pemBytes, err := readCAKeyPEMInternal(caKeyPath) - require.NoError(t, err) - require.NoError(t, os.WriteFile(caKeyPath, pemBytes, 0o600)) - - originalEncrypt := caKeyEncryptInternal - t.Cleanup(func() { - caKeyEncryptInternal = originalEncrypt - }) - caKeyEncryptInternal = func(string) (string, error) { - return "", errors.New("encrypt failed") - } - - _, _, _, err = ensureManagerCAInternal(context.Background(), assetsDir) - require.Error(t, err) - require.Contains(t, err.Error(), "failed to migrate edge mTLS CA key to encrypted format") -} - func TestCAKey_EncryptionFailureReturnsError(t *testing.T) { originalEncrypt := caKeyEncryptInternal t.Cleanup(func() { diff --git a/backend/pkg/libarcane/edge/transport.go b/backend/pkg/libarcane/edge/transport.go index e5fac62c65..11a96a84ac 100644 --- a/backend/pkg/libarcane/edge/transport.go +++ b/backend/pkg/libarcane/edge/transport.go @@ -84,9 +84,6 @@ const ( // EdgeTransportPoll uses an HTTP polling control plane with the existing // websocket tunnel as an on-demand data plane. EdgeTransportPoll = "poll" - // EdgeTransportAuto preserves the legacy managed tunnel behavior: try gRPC - // first and fall back to websocket when available. - EdgeTransportAuto = "auto" // EdgeMTLSModeDisabled disables edge tunnel mTLS. EdgeMTLSModeDisabled = "disabled" @@ -97,8 +94,7 @@ const ( EdgeMTLSModeRequired = "required" ) -// NormalizeEdgeTransport normalizes transport config and defaults to the legacy -// managed tunnel auto mode for backwards compatibility. +// NormalizeEdgeTransport normalizes transport config and defaults to gRPC. func NormalizeEdgeTransport(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case EdgeTransportWebSocket: @@ -107,10 +103,8 @@ func NormalizeEdgeTransport(value string) string { return EdgeTransportGRPC case EdgeTransportPoll: return EdgeTransportPoll - case EdgeTransportAuto: - return EdgeTransportAuto default: - return EdgeTransportAuto + return EdgeTransportGRPC } } @@ -131,8 +125,7 @@ func UseGRPCEdgeTransport(cfg *Config) bool { if cfg == nil { return false } - transport := NormalizeEdgeTransport(cfg.EdgeTransport) - return transport == EdgeTransportGRPC || transport == EdgeTransportAuto + return NormalizeEdgeTransport(cfg.EdgeTransport) == EdgeTransportGRPC } // UseWebSocketEdgeTransport reports whether websocket managed tunnel mode is allowed. @@ -140,8 +133,7 @@ func UseWebSocketEdgeTransport(cfg *Config) bool { if cfg == nil { return false } - transport := NormalizeEdgeTransport(cfg.EdgeTransport) - return transport == EdgeTransportWebSocket || transport == EdgeTransportAuto + return NormalizeEdgeTransport(cfg.EdgeTransport) == EdgeTransportWebSocket } // UsePollEdgeTransport reports whether the Portainer-style polling control plane diff --git a/backend/pkg/libarcane/edge/transport_test.go b/backend/pkg/libarcane/edge/transport_test.go index 3e4b2e6635..7f21a2afd4 100644 --- a/backend/pkg/libarcane/edge/transport_test.go +++ b/backend/pkg/libarcane/edge/transport_test.go @@ -9,13 +9,12 @@ import ( ) func TestNormalizeEdgeTransport(t *testing.T) { - assert.Equal(t, EdgeTransportAuto, NormalizeEdgeTransport("")) + assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("")) assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("grpc")) assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("GRPC")) assert.Equal(t, EdgeTransportPoll, NormalizeEdgeTransport("poll")) assert.Equal(t, EdgeTransportWebSocket, NormalizeEdgeTransport("websocket")) - assert.Equal(t, EdgeTransportAuto, NormalizeEdgeTransport("invalid")) - assert.Equal(t, EdgeTransportAuto, NormalizeEdgeTransport("auto")) + assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("invalid")) } func TestNormalizeEdgeMTLSMode(t *testing.T) { @@ -29,14 +28,12 @@ func TestUseGRPCEdgeTransport(t *testing.T) { assert.False(t, UseGRPCEdgeTransport(nil)) assert.True(t, UseGRPCEdgeTransport(&Config{EdgeTransport: "grpc"})) assert.True(t, UseGRPCEdgeTransport(&Config{EdgeTransport: ""})) - assert.True(t, UseGRPCEdgeTransport(&Config{EdgeTransport: "auto"})) assert.False(t, UseGRPCEdgeTransport(&Config{EdgeTransport: "websocket"})) } func TestUseWebSocketEdgeTransport(t *testing.T) { assert.False(t, UseWebSocketEdgeTransport(nil)) - assert.True(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: ""})) - assert.True(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "auto"})) + assert.False(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: ""})) assert.False(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "grpc"})) assert.False(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "poll"})) assert.True(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "websocket"})) @@ -45,7 +42,6 @@ func TestUseWebSocketEdgeTransport(t *testing.T) { func TestUsePollEdgeTransport(t *testing.T) { assert.False(t, UsePollEdgeTransport(nil)) assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: ""})) - assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: "auto"})) assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: "grpc"})) assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: "websocket"})) assert.True(t, UsePollEdgeTransport(&Config{EdgeTransport: "poll"})) diff --git a/backend/pkg/libarcane/internal_resource.go b/backend/pkg/libarcane/internal_resource.go index e4aeb519cc..89b4014406 100644 --- a/backend/pkg/libarcane/internal_resource.go +++ b/backend/pkg/libarcane/internal_resource.go @@ -3,18 +3,14 @@ package libarcane import "strings" // Internal containers indicate containers used for arcanes utilties, ie: temp containers used for viewing files for volumes etc -const ( - InternalResourceLabel = "com.getarcaneapp.internal.resource" - // Deprecated - Legacy label. Use InternalResourceLabel instead. - legacyInternalContainerLabel = "com.getarcaneapp.internal.container" -) +const InternalResourceLabel = "com.getarcaneapp.internal.resource" func IsInternalContainer(labels map[string]string) bool { if labels == nil { return false } for k, v := range labels { - if strings.EqualFold(k, InternalResourceLabel) || strings.EqualFold(k, legacyInternalContainerLabel) { + if strings.EqualFold(k, InternalResourceLabel) { switch strings.TrimSpace(strings.ToLower(v)) { case "true", "1", "yes", "on": return true diff --git a/backend/pkg/libarcane/startup/startup.go b/backend/pkg/libarcane/startup/startup.go index eae84c7b9f..3c246812df 100644 --- a/backend/pkg/libarcane/startup/startup.go +++ b/backend/pkg/libarcane/startup/startup.go @@ -2,11 +2,7 @@ package startup import ( "context" - "fmt" "log/slog" - "strconv" - "strings" - "time" "github.com/getarcaneapp/arcane/backend/buildables" ) @@ -50,11 +46,6 @@ type SettingsPruner interface { PruneUnknownSettings(ctx context.Context) error } -type IntervalMigrationItem struct { - ID string - RawValue string -} - func InitializeDefaultSettings(ctx context.Context, cfg *RuntimeConfig, settingsMgr SettingsManager) { slog.InfoContext(ctx, "Ensuring default settings are initialized") @@ -89,8 +80,6 @@ func InitializeNonAgentFeatures( createAdminFunc func(context.Context) error, reconcileDefaultAdminAPIKeyFunc func(context.Context) error, autoLoginInitFunc func(context.Context) error, - migrateOidcFunc func(context.Context) error, - migrateDiscordFunc func(context.Context) error, ) { if cfg.AgentMode { return @@ -109,20 +98,6 @@ func InitializeNonAgentFeatures( if err := autoLoginInitFunc(ctx); err != nil { slog.WarnContext(ctx, "Failed to initialize auto-login", "error", err.Error()) } - - // Migrate legacy OIDC JSON config to individual fields (runs before env sync) - if migrateOidcFunc != nil { - if err := migrateOidcFunc(ctx); err != nil { - slog.WarnContext(ctx, "Failed to migrate OIDC config to individual fields", "error", err.Error()) - } - } - - // Migrate legacy Discord webhookUrl to separate webhookId and token fields - if migrateDiscordFunc != nil { - if err := migrateDiscordFunc(ctx); err != nil { - slog.WarnContext(ctx, "Failed to migrate Discord webhook config", "error", err.Error()) - } - } } func InitializeAutoLogin(ctx context.Context, cfg *RuntimeConfig) { @@ -132,331 +107,3 @@ func InitializeAutoLogin(ctx context.Context, cfg *RuntimeConfig) { slog.WarnContext(ctx, "⚠️ AUTO-LOGIN IS ENABLED - This is a security risk! Do NOT use in production.", "username", cfg.AutoLoginUsername) slog.WarnContext(ctx, "⚠️ Auto-login will automatically authenticate users without requiring credentials.") } - -func MigrateSchedulerCronValues( - ctx context.Context, - getSettingFunc func(context.Context, string, string) string, - updateSettingFunc func(context.Context, string, string) error, - reloadFunc func(context.Context) error, -) { - migrations := []struct { - key string - defaultUnit time.Duration - }{ - {key: "pollingInterval", defaultUnit: time.Minute}, - {key: "autoUpdateInterval", defaultUnit: time.Minute}, - {key: "scheduledPruneInterval", defaultUnit: time.Minute}, - {key: "environmentHealthInterval", defaultUnit: time.Minute}, - {key: "eventCleanupInterval", defaultUnit: time.Minute}, - {key: "expiredSessionsCleanupInterval", defaultUnit: time.Minute}, - } - - changed := false - for _, item := range migrations { - raw := strings.TrimSpace(getSettingFunc(ctx, item.key, "")) - if raw == "" { - continue - } - - cronValue, shouldUpdate, warn := normalizeSchedulerValueToCron(raw, item.defaultUnit) - if warn != "" { - slog.WarnContext(ctx, warn, "key", item.key, "value", raw) - } - if !shouldUpdate { - continue - } - if cronValue == "" || cronValue == raw { - continue - } - if err := updateSettingFunc(ctx, item.key, cronValue); err != nil { - slog.WarnContext(ctx, "Failed to migrate scheduler setting", "key", item.key, "error", err.Error()) - continue - } - slog.InfoContext(ctx, "Migrated scheduler setting to cron", "key", item.key, "value", cronValue) - changed = true - } - - if changed && reloadFunc != nil { - if err := reloadFunc(ctx); err != nil { - slog.WarnContext(ctx, "Failed to reload settings after scheduler migration", "error", err.Error()) - } - } -} - -func MigrateGitOpsSyncIntervals( - ctx context.Context, - listFunc func(context.Context) ([]IntervalMigrationItem, error), - updateFunc func(context.Context, string, int) error, -) { - if listFunc == nil || updateFunc == nil { - return - } - - items, err := listFunc(ctx) - if err != nil { - slog.WarnContext(ctx, "Failed to load git sync intervals for migration", "error", err.Error()) - return - } - - for _, item := range items { - raw := strings.TrimSpace(item.RawValue) - if raw == "" { - continue - } - minutes, shouldUpdate, warn := normalizeSchedulerValueToMinutes(raw, time.Minute) - if warn != "" { - slog.WarnContext(ctx, warn, "syncId", item.ID, "value", raw) - } - if !shouldUpdate || minutes <= 0 { - continue - } - if err := updateFunc(ctx, item.ID, minutes); err != nil { - slog.WarnContext(ctx, "Failed to migrate git sync interval", "syncId", item.ID, "error", err.Error()) - continue - } - slog.InfoContext(ctx, "Migrated git sync interval", "syncId", item.ID, "minutes", minutes) - } -} - -func normalizeSchedulerValueToCron(raw string, defaultUnit time.Duration) (string, bool, string) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "", false, "" - } - - if strings.HasPrefix(trimmed, "@") { - return trimmed, false, "" - } - - if strings.HasPrefix(trimmed, "CRON_TZ=") || strings.HasPrefix(trimmed, "TZ=") { - parts := strings.Fields(trimmed) - if len(parts) == 6 { - return parts[0] + " 0 " + strings.Join(parts[1:], " "), true, "" - } - return trimmed, false, "" - } - - fields := strings.Fields(trimmed) - if len(fields) == 5 { - return "0 " + trimmed, true, "" - } - if len(fields) >= 6 { - return trimmed, false, "" - } - - duration, ok := parseSchedulerDuration(trimmed, defaultUnit) - if !ok { - return "", false, "" - } - - cronValue, warn := durationToCron(duration) - if cronValue == "" { - return "", false, warn - } - - return cronValue, true, warn -} - -func parseSchedulerDuration(raw string, defaultUnit time.Duration) (time.Duration, bool) { - if raw == "" { - return 0, false - } - if d, err := time.ParseDuration(raw); err == nil { - return d, true - } - value, err := strconv.Atoi(raw) - if err != nil { - return 0, false - } - if defaultUnit <= 0 { - defaultUnit = time.Minute - } - return time.Duration(value) * defaultUnit, true -} - -func durationToCron(d time.Duration) (string, string) { - if d <= 0 { - return "", "" - } - - if d < time.Minute { - seconds := int(d.Seconds()) - if d%time.Second != 0 { - seconds++ - } - if seconds < 1 { - return "", "" - } - if seconds >= 60 { - minutes := (seconds + 59) / 60 - return minutesToCron(minutes), "scheduler interval rounded up to minutes" - } - return fmt.Sprintf("*/%d * * * * *", seconds), "" - } - - if d%time.Minute != 0 { - seconds := int(d.Seconds()) - if d%time.Second != 0 { - seconds++ - } - minutes := (seconds + 59) / 60 - return minutesToCron(minutes), "scheduler interval rounded up to minutes" - } - - minutes := int(d.Minutes()) - return minutesToCron(minutes), "" -} - -func minutesToCron(minutes int) string { - if minutes <= 0 { - return "" - } - if minutes < 60 { - return fmt.Sprintf("0 */%d * * * *", minutes) - } - if minutes%60 == 0 { - hours := minutes / 60 - if hours < 24 { - return fmt.Sprintf("0 0 */%d * * *", hours) - } - if hours%24 == 0 { - days := hours / 24 - return fmt.Sprintf("0 0 0 */%d * *", days) - } - } - return fmt.Sprintf("0 */%d * * * *", minutes) -} - -func normalizeSchedulerValueToMinutes(raw string, defaultUnit time.Duration) (int, bool, string) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return 0, false, "" - } - - if strings.HasPrefix(trimmed, "@") { - return 0, false, "" - } - - if strings.HasPrefix(trimmed, "CRON_TZ=") || strings.HasPrefix(trimmed, "TZ=") { - parts := strings.Fields(trimmed) - if len(parts) >= 6 { - minutes, ok, warn := cronToMinutes(parts[1:]) - if ok { - return minutes, true, warn - } - } - return 0, false, "" - } - - fields := strings.Fields(trimmed) - if len(fields) >= 5 { - if len(fields) == 5 { - fields = append([]string{"0"}, fields...) - } - minutes, ok, warn := cronToMinutes(fields) - if ok { - return minutes, true, warn - } - return 0, false, "" - } - - duration, ok := parseSchedulerDuration(trimmed, defaultUnit) - if !ok { - return 0, false, "" - } - - minutes := int(duration / time.Minute) - warn := "" - if duration%time.Minute != 0 { - minutes++ - warn = "scheduler interval rounded up to minutes" - } - if minutes < 1 { - minutes = 1 - warn = "scheduler interval rounded up to minutes" - } - if strconv.Itoa(minutes) == trimmed { - return minutes, false, warn - } - - return minutes, true, warn -} - -func cronToMinutes(fields []string) (int, bool, string) { - if len(fields) < 6 { - return 0, false, "" - } - - sec, min, hour, day, month, weekday := fields[0], fields[1], fields[2], fields[3], fields[4], fields[5] - - if mins, ok, warn := tryConvertSecondStep(sec, min, hour, day, month, weekday); ok { - return mins, ok, warn - } - if mins, ok, warn := tryConvertMinuteOrHour(sec, min, hour, day, month, weekday); ok { - return mins, ok, warn - } - if mins, ok, warn := tryConvertDayStep(sec, min, hour, day, month, weekday); ok { - return mins, ok, warn - } - - return 0, false, "" -} - -func tryConvertSecondStep(sec, min, hour, day, month, weekday string) (int, bool, string) { - step, ok := parseCronStep(sec) - if !ok { - return 0, false, "" - } - - if min == "*" && hour == "*" && day == "*" && month == "*" && weekday == "*" { - minutes := max((step+59)/60, 1) - warn := "" - if step%60 != 0 { - warn = "scheduler interval rounded up to minutes" - } - return minutes, true, warn - } - return 0, false, "" -} - -func tryConvertMinuteOrHour(sec, min, hour, day, month, weekday string) (int, bool, string) { - if (sec != "0" && sec != "*") || day != "*" || month != "*" || weekday != "*" { - return 0, false, "" - } - - if step, ok := parseCronStep(min); ok && hour == "*" { - return step, true, "" - } - if min == "*" && hour == "*" { - return 1, true, "" - } - if min == "0" { - if step, ok := parseCronStep(hour); ok && day == "*" { - return step * 60, true, "" - } - if hour == "*" { - return 60, true, "" - } - } - return 0, false, "" -} - -func tryConvertDayStep(sec, min, hour, day, month, weekday string) (int, bool, string) { - if (sec == "0" || sec == "*") && min == "0" && hour == "0" && month == "*" && weekday == "*" { - if step, ok := parseCronStep(day); ok { - return step * 1440, true, "" - } - } - return 0, false, "" -} - -func parseCronStep(field string) (int, bool) { - if !strings.HasPrefix(field, "*/") { - return 0, false - } - step, err := strconv.Atoi(strings.TrimPrefix(field, "*/")) - if err != nil || step <= 0 { - return 0, false - } - return step, true -} diff --git a/backend/pkg/libarcane/startup/startup_test.go b/backend/pkg/libarcane/startup/startup_test.go index 7c027eb241..4be6e8aaf0 100644 --- a/backend/pkg/libarcane/startup/startup_test.go +++ b/backend/pkg/libarcane/startup/startup_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "testing" - "time" "github.com/stretchr/testify/assert" ) @@ -53,14 +52,14 @@ func TestLoadAgentToken(t *testing.T) { } getSettingFunc := func(ctx context.Context, key string, def string) string { if key == "agentToken" { - return "test-token-123" + return "loaded-token" } return def } LoadAgentToken(ctx, cfg, getSettingFunc) - assert.Equal(t, "test-token-123", cfg.AgentToken) + assert.Equal(t, "loaded-token", cfg.AgentToken) }) t.Run("does not load when not in agent mode", func(t *testing.T) { @@ -68,66 +67,57 @@ func TestLoadAgentToken(t *testing.T) { AgentMode: false, AgentToken: "", } + called := false getSettingFunc := func(ctx context.Context, key string, def string) string { - return "test-token-123" + called = true + return "" } LoadAgentToken(ctx, cfg, getSettingFunc) + assert.False(t, called) assert.Empty(t, cfg.AgentToken) }) - t.Run("does not override existing token", func(t *testing.T) { + t.Run("does not load when token already set", func(t *testing.T) { cfg := &RuntimeConfig{ AgentMode: true, AgentToken: "existing-token", } + called := false getSettingFunc := func(ctx context.Context, key string, def string) string { - return "new-token" - } - - LoadAgentToken(ctx, cfg, getSettingFunc) - - assert.Equal(t, "existing-token", cfg.AgentToken) - }) - - t.Run("handles empty token from database", func(t *testing.T) { - cfg := &RuntimeConfig{ - AgentMode: true, - AgentToken: "", - } - getSettingFunc := func(ctx context.Context, key string, def string) string { + called = true return "" } LoadAgentToken(ctx, cfg, getSettingFunc) - assert.Empty(t, cfg.AgentToken) + assert.False(t, called) + assert.Equal(t, "existing-token", cfg.AgentToken) }) } func TestEnsureEncryptionKey(t *testing.T) { ctx := context.Background() - t.Run("sets key in agent mode", func(t *testing.T) { + t.Run("sets key when in agent mode", func(t *testing.T) { cfg := &RuntimeConfig{ - AgentMode: true, - EncryptionKey: "", + AgentMode: true, + Environment: "production", } ensureKeyFunc := func(ctx context.Context) (string, error) { - return "generated-key", nil + return "secret-key", nil } EnsureEncryptionKey(ctx, cfg, ensureKeyFunc) - assert.Equal(t, "generated-key", cfg.EncryptionKey) + assert.Equal(t, "secret-key", cfg.EncryptionKey) }) - t.Run("sets key in non-production", func(t *testing.T) { + t.Run("sets key when not in production", func(t *testing.T) { cfg := &RuntimeConfig{ - AgentMode: false, - Environment: "development", - EncryptionKey: "", + AgentMode: false, + Environment: "development", } ensureKeyFunc := func(ctx context.Context) (string, error) { return "dev-key", nil @@ -138,25 +128,28 @@ func TestEnsureEncryptionKey(t *testing.T) { assert.Equal(t, "dev-key", cfg.EncryptionKey) }) - t.Run("does not set key in production when not agent", func(t *testing.T) { + t.Run("does not set key in production non-agent mode", func(t *testing.T) { cfg := &RuntimeConfig{ AgentMode: false, Environment: "production", - EncryptionKey: "", + EncryptionKey: "existing", } + called := false ensureKeyFunc := func(ctx context.Context) (string, error) { - return "should-not-set", nil + called = true + return "new-key", nil } EnsureEncryptionKey(ctx, cfg, ensureKeyFunc) - assert.Empty(t, cfg.EncryptionKey) + assert.False(t, called) + assert.Equal(t, "existing", cfg.EncryptionKey) }) t.Run("handles error gracefully", func(t *testing.T) { cfg := &RuntimeConfig{ AgentMode: true, - EncryptionKey: "", + EncryptionKey: "fallback", } ensureKeyFunc := func(ctx context.Context) (string, error) { return "", errors.New("key generation failed") @@ -164,7 +157,7 @@ func TestEnsureEncryptionKey(t *testing.T) { EnsureEncryptionKey(ctx, cfg, ensureKeyFunc) - assert.Empty(t, cfg.EncryptionKey) + assert.Equal(t, "fallback", cfg.EncryptionKey) }) } @@ -266,7 +259,7 @@ func TestInitializeNonAgentFeatures(t *testing.T) { return nil } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, nil, nil, nil) + InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, nil) assert.False(t, createAdminCalled) assert.False(t, reconcileDefaultAdminAPIKeyCalled) @@ -276,8 +269,6 @@ func TestInitializeNonAgentFeatures(t *testing.T) { cfg := &RuntimeConfig{AgentMode: false} createAdminCalled := false reconcileDefaultAdminAPIKeyCalled := false - migrateOidcCalled := false - migrateDiscordCalled := false autoLoginInitCalled := false createAdminFunc := func(ctx context.Context) error { @@ -292,21 +283,11 @@ func TestInitializeNonAgentFeatures(t *testing.T) { autoLoginInitCalled = true return nil } - migrateOidcFunc := func(ctx context.Context) error { - migrateOidcCalled = true - return nil - } - migrateDiscordFunc := func(ctx context.Context) error { - migrateDiscordCalled = true - return nil - } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc, migrateOidcFunc, migrateDiscordFunc) + InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) assert.True(t, createAdminCalled) assert.True(t, reconcileDefaultAdminAPIKeyCalled) - assert.True(t, migrateOidcCalled) - assert.True(t, migrateDiscordCalled) assert.True(t, autoLoginInitCalled) }) @@ -321,429 +302,7 @@ func TestInitializeNonAgentFeatures(t *testing.T) { createAdminFunc := func(ctx context.Context) error { return errors.New("admin creation failed") } - migrateOidcFunc := func(ctx context.Context) error { - return errors.New("oidc migration failed") - } - migrateDiscordFunc := func(ctx context.Context) error { - return errors.New("discord migration failed") - } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc, migrateOidcFunc, migrateDiscordFunc) + InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) }) } - -func TestMigrateSchedulerCronValues(t *testing.T) { - ctx := context.Background() - - t.Run("migrates minute-based intervals to cron", func(t *testing.T) { - settings := map[string]string{ - "pollingInterval": "15", - } - updateCalled := false - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - updateCalled = true - assert.Equal(t, "pollingInterval", key) - assert.Equal(t, "0 */15 * * * *", value) - return nil - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - - assert.True(t, updateCalled) - }) - - t.Run("migrates duration strings", func(t *testing.T) { - settings := map[string]string{ - "autoUpdateInterval": "30m", - } - updateCalled := false - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - updateCalled = true - assert.Equal(t, "autoUpdateInterval", key) - assert.Equal(t, "0 */30 * * * *", value) - return nil - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - - assert.True(t, updateCalled) - }) - - t.Run("skips already valid cron expressions", func(t *testing.T) { - settings := map[string]string{ - "pollingInterval": "0 */15 * * * *", - } - updateCalled := false - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - updateCalled = true - return nil - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - - assert.False(t, updateCalled) - }) - - t.Run("handles update errors", func(t *testing.T) { - settings := map[string]string{ - "pollingInterval": "15", - } - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - return errors.New("update failed") - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - }) -} - -func TestMigrateGitOpsSyncIntervals(t *testing.T) { - ctx := context.Background() - - t.Run("migrates intervals to minutes", func(t *testing.T) { - items := []IntervalMigrationItem{ - {ID: "sync-1", RawValue: "30"}, - {ID: "sync-2", RawValue: "1h"}, - } - updates := make(map[string]int) - - listFunc := func(ctx context.Context) ([]IntervalMigrationItem, error) { - return items, nil - } - updateFunc := func(ctx context.Context, id string, minutes int) error { - updates[id] = minutes - return nil - } - - MigrateGitOpsSyncIntervals(ctx, listFunc, updateFunc) - - assert.Len(t, updates, 1) - assert.Equal(t, 60, updates["sync-2"]) - }) - - t.Run("handles list error", func(t *testing.T) { - listFunc := func(ctx context.Context) ([]IntervalMigrationItem, error) { - return nil, errors.New("list failed") - } - updateFunc := func(ctx context.Context, id string, minutes int) error { - return nil - } - - MigrateGitOpsSyncIntervals(ctx, listFunc, updateFunc) - }) - - t.Run("handles nil functions", func(t *testing.T) { - MigrateGitOpsSyncIntervals(ctx, nil, nil) - }) -} - -func TestParseSchedulerDuration(t *testing.T) { - tests := []struct { - name string - raw string - defaultUnit time.Duration - wantDuration time.Duration - wantOk bool - }{ - {"empty string", "", time.Minute, 0, false}, - {"valid duration string", "30m", time.Minute, 30 * time.Minute, true}, - {"integer with default minute", "15", time.Minute, 15 * time.Minute, true}, - {"integer with default hour", "2", time.Hour, 2 * time.Hour, true}, - {"seconds duration", "45s", time.Minute, 45 * time.Second, true}, - {"hours duration", "2h", time.Minute, 2 * time.Hour, true}, - {"invalid format", "abc", time.Minute, 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotDuration, gotOk := parseSchedulerDuration(tt.raw, tt.defaultUnit) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantDuration, gotDuration) - } - }) - } -} - -func TestDurationToCron(t *testing.T) { - tests := []struct { - name string - duration time.Duration - wantCron string - wantWarn string - }{ - {"zero duration", 0, "", ""}, - {"negative duration", -1 * time.Minute, "", ""}, - {"30 seconds", 30 * time.Second, "*/30 * * * * *", ""}, - {"1 minute", time.Minute, "0 */1 * * * *", ""}, - {"15 minutes", 15 * time.Minute, "0 */15 * * * *", ""}, - {"1 hour", time.Hour, "0 0 */1 * * *", ""}, - {"24 hours", 24 * time.Hour, "0 0 0 */1 * *", ""}, - {"45 seconds rounds up", 45 * time.Second, "*/45 * * * * *", ""}, - {"90 seconds rounds to 2 min", 90 * time.Second, "0 */2 * * * *", "scheduler interval rounded up to minutes"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCron, gotWarn := durationToCron(tt.duration) - assert.Equal(t, tt.wantCron, gotCron) - if tt.wantWarn != "" { - assert.Contains(t, gotWarn, tt.wantWarn) - } - }) - } -} - -func TestMinutesToCron(t *testing.T) { - tests := []struct { - name string - minutes int - wantCron string - }{ - {"zero minutes", 0, ""}, - {"negative minutes", -1, ""}, - {"15 minutes", 15, "0 */15 * * * *"}, - {"30 minutes", 30, "0 */30 * * * *"}, - {"60 minutes", 60, "0 0 */1 * * *"}, - {"120 minutes", 120, "0 0 */2 * * *"}, - {"1440 minutes (1 day)", 1440, "0 0 0 */1 * *"}, - {"2880 minutes (2 days)", 2880, "0 0 0 */2 * *"}, - {"90 minutes", 90, "0 */90 * * * *"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCron := minutesToCron(tt.minutes) - assert.Equal(t, tt.wantCron, gotCron) - }) - } -} - -func TestNormalizeSchedulerValueToCron(t *testing.T) { - tests := []struct { - name string - raw string - defaultUnit time.Duration - wantCron string - wantShouldUpdate bool - }{ - {"empty string", "", time.Minute, "", false}, - {"@hourly special", "@hourly", time.Minute, "@hourly", false}, - {"valid 6-field cron", "0 */15 * * * *", time.Minute, "0 */15 * * * *", false}, - {"5-field cron adds second", "*/15 * * * *", time.Minute, "0 */15 * * * *", true}, - {"integer minutes", "30", time.Minute, "0 */30 * * * *", true}, - {"duration string", "1h", time.Minute, "0 0 */1 * * *", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCron, gotShouldUpdate, _ := normalizeSchedulerValueToCron(tt.raw, tt.defaultUnit) - assert.Equal(t, tt.wantCron, gotCron) - assert.Equal(t, tt.wantShouldUpdate, gotShouldUpdate) - }) - } -} - -func TestNormalizeSchedulerValueToMinutes(t *testing.T) { - tests := []struct { - name string - raw string - defaultUnit time.Duration - wantMinutes int - wantShouldUpdate bool - }{ - {"empty string", "", time.Minute, 0, false}, - {"@hourly special", "@hourly", time.Minute, 0, false}, - {"integer already minutes", "30", time.Minute, 30, false}, - {"duration string", "1h", time.Minute, 60, true}, - {"cron every hour", "0 0 * * * *", time.Minute, 60, true}, - {"cron every 15 min", "0 */15 * * * *", time.Minute, 15, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotShouldUpdate, _ := normalizeSchedulerValueToMinutes(tt.raw, tt.defaultUnit) - assert.Equal(t, tt.wantMinutes, gotMinutes) - assert.Equal(t, tt.wantShouldUpdate, gotShouldUpdate) - }) - } -} - -func TestCronToMinutes(t *testing.T) { - tests := []struct { - name string - fields []string - wantMinutes int - wantOk bool - }{ - {"every 30 seconds", []string{"*/30", "*", "*", "*", "*", "*"}, 1, true}, - {"every 15 minutes", []string{"0", "*/15", "*", "*", "*", "*"}, 15, true}, - {"every hour", []string{"0", "0", "*", "*", "*", "*"}, 60, true}, - {"every 2 hours", []string{"0", "0", "*/2", "*", "*", "*"}, 120, true}, - {"every day", []string{"0", "0", "0", "*/1", "*", "*"}, 1440, true}, - {"too few fields", []string{"0", "*/15", "*", "*", "*"}, 0, false}, - {"complex pattern", []string{"0", "15", "*/2", "*", "*", "*"}, 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := cronToMinutes(tt.fields) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} - -func TestParseCronStep(t *testing.T) { - tests := []struct { - name string - field string - wantStep int - wantOk bool - }{ - {"valid step", "*/15", 15, true}, - {"no prefix", "15", 0, false}, - {"zero step", "*/0", 0, false}, - {"negative step", "*/-5", 0, false}, - {"invalid number", "*/abc", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotStep, gotOk := parseCronStep(tt.field) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantStep, gotStep) - } - }) - } -} - -func TestTryConvertSecondStep(t *testing.T) { - tests := []struct { - name string - sec string - min string - hour string - day string - month string - weekday string - wantMinutes int - wantOk bool - }{ - {"every 30 seconds", "*/30", "*", "*", "*", "*", "*", 1, true}, - {"every 60 seconds", "*/60", "*", "*", "*", "*", "*", 1, true}, - {"every 90 seconds", "*/90", "*", "*", "*", "*", "*", 2, true}, - {"not wildcard minutes", "*/30", "0", "*", "*", "*", "*", 0, false}, - {"not step pattern", "0", "*", "*", "*", "*", "*", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := tryConvertSecondStep(tt.sec, tt.min, tt.hour, tt.day, tt.month, tt.weekday) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} - -func TestTryConvertMinuteOrHour(t *testing.T) { - tests := []struct { - name string - sec string - min string - hour string - day string - month string - weekday string - wantMinutes int - wantOk bool - }{ - {"every 15 minutes", "0", "*/15", "*", "*", "*", "*", 15, true}, - {"every hour", "0", "0", "*", "*", "*", "*", 60, true}, - {"every 2 hours", "0", "0", "*/2", "*", "*", "*", 120, true}, - {"complex pattern", "0", "30", "*/2", "*", "*", "*", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := tryConvertMinuteOrHour(tt.sec, tt.min, tt.hour, tt.day, tt.month, tt.weekday) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} - -func TestTryConvertDayStep(t *testing.T) { - tests := []struct { - name string - sec string - min string - hour string - day string - month string - weekday string - wantMinutes int - wantOk bool - }{ - {"every day", "0", "0", "0", "*/1", "*", "*", 1440, true}, - {"every 2 days", "0", "0", "0", "*/2", "*", "*", 2880, true}, - {"not midnight", "0", "0", "1", "*/1", "*", "*", 0, false}, - {"not step pattern", "0", "0", "0", "1", "*", "*", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := tryConvertDayStep(tt.sec, tt.min, tt.hour, tt.day, tt.month, tt.weekday) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} diff --git a/backend/pkg/scheduler/auto_update_job.go b/backend/pkg/scheduler/auto_update_job.go index 7f6ddefad7..9429a170de 100644 --- a/backend/pkg/scheduler/auto_update_job.go +++ b/backend/pkg/scheduler/auto_update_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" ) @@ -36,21 +34,6 @@ func (j *AutoUpdateJob) Schedule(ctx context.Context) string { if s == "" { return "0 0 0 * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 1440 - } - if i%1440 == 0 { - return fmt.Sprintf("0 0 0 */%d * *", i/1440) - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/environment_health_job.go b/backend/pkg/scheduler/environment_health_job.go index 514ac798f2..ffb39be33d 100644 --- a/backend/pkg/scheduler/environment_health_job.go +++ b/backend/pkg/scheduler/environment_health_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "sync/atomic" "time" @@ -48,18 +46,6 @@ func (j *EnvironmentHealthJob) Schedule(ctx context.Context) string { if s == "" { return "0 */2 * * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 2 - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/event_cleanup_job.go b/backend/pkg/scheduler/event_cleanup_job.go index 96429970ff..c49c7956f9 100644 --- a/backend/pkg/scheduler/event_cleanup_job.go +++ b/backend/pkg/scheduler/event_cleanup_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "time" "github.com/getarcaneapp/arcane/backend/internal/services" @@ -33,18 +31,6 @@ func (j *EventCleanupJob) Schedule(ctx context.Context) string { if s == "" { return "0 0 */6 * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 360 - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/gitops_sync_job.go b/backend/pkg/scheduler/gitops_sync_job.go index a90ccab60e..b1ac03e953 100644 --- a/backend/pkg/scheduler/gitops_sync_job.go +++ b/backend/pkg/scheduler/gitops_sync_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/robfig/cron/v3" @@ -36,18 +34,6 @@ func (j *GitOpsSyncJob) Schedule(ctx context.Context) string { schedule = "0 */1 * * * *" } - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(schedule); err == nil { - if i <= 0 { - i = 1 - } - if i%60 == 0 { - schedule = fmt.Sprintf("0 0 */%d * * *", i/60) - } else { - schedule = fmt.Sprintf("0 */%d * * * *", i) - } - } - parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if _, err := parser.Parse(schedule); err != nil { slog.WarnContext(ctx, "Invalid cron expression for gitops-sync, using default", "invalid_schedule", schedule, "error", err) diff --git a/backend/pkg/scheduler/gitops_sync_job_test.go b/backend/pkg/scheduler/gitops_sync_job_test.go index 85fcd6da0e..fe13620b19 100644 --- a/backend/pkg/scheduler/gitops_sync_job_test.go +++ b/backend/pkg/scheduler/gitops_sync_job_test.go @@ -26,16 +26,6 @@ func TestGitOpsSyncJobSchedule_UsesConfiguredCron(t *testing.T) { require.Equal(t, "0 */7 * * * *", got) } -func TestGitOpsSyncJobSchedule_LegacyIntegerMinutes(t *testing.T) { - ctx := context.Background() - _, settingsSvc, _ := setupAnalyticsStateServicesInternal(t) - require.NoError(t, settingsSvc.SetStringSetting(ctx, "gitopsSyncInterval", "120")) - job := NewGitOpsSyncJob(nil, settingsSvc) - - got := job.Schedule(ctx) - require.Equal(t, "0 0 */2 * * *", got) -} - func TestGitOpsSyncJobSchedule_InvalidCronFallsBackToDefault(t *testing.T) { ctx := context.Background() _, settingsSvc, _ := setupAnalyticsStateServicesInternal(t) diff --git a/backend/pkg/scheduler/image_polling_job.go b/backend/pkg/scheduler/image_polling_job.go index fab417f3f2..bb5126d3fa 100644 --- a/backend/pkg/scheduler/image_polling_job.go +++ b/backend/pkg/scheduler/image_polling_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" ) @@ -36,18 +34,6 @@ func (j *ImagePollingJob) Schedule(ctx context.Context) string { if s == "" { return "0 0 * * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 60 - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/scheduled_prune_job.go b/backend/pkg/scheduler/scheduled_prune_job.go index 6b4810ccd2..238609dd65 100644 --- a/backend/pkg/scheduler/scheduled_prune_job.go +++ b/backend/pkg/scheduler/scheduled_prune_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/types/system" @@ -41,21 +39,6 @@ func (j *ScheduledPruneJob) Schedule(ctx context.Context) string { schedule = "0 0 0 * * *" } - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(schedule); err == nil { - if i <= 0 { - i = 1440 - } - switch { - case i%1440 == 0: - schedule = fmt.Sprintf("0 0 0 */%d * *", i/1440) - case i%60 == 0: - schedule = fmt.Sprintf("0 0 */%d * * *", i/60) - default: - schedule = fmt.Sprintf("0 */%d * * * *", i) - } - } - parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if _, err := parser.Parse(schedule); err != nil { slog.WarnContext(ctx, "Invalid cron expression for scheduled-prune, using default", "invalid_schedule", schedule, "error", err) diff --git a/backend/pkg/scheduler/vulnerability_scan_job.go b/backend/pkg/scheduler/vulnerability_scan_job.go index 126c59d342..090ce325d9 100644 --- a/backend/pkg/scheduler/vulnerability_scan_job.go +++ b/backend/pkg/scheduler/vulnerability_scan_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" @@ -49,21 +47,6 @@ func (j *VulnerabilityScanJob) Schedule(ctx context.Context) string { schedule = "0 0 0 * * *" } - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(schedule); err == nil { - if i <= 0 { - i = 1440 - } - switch { - case i%1440 == 0: - schedule = fmt.Sprintf("0 0 0 */%d * *", i/1440) - case i%60 == 0: - schedule = fmt.Sprintf("0 0 */%d * * *", i/60) - default: - schedule = fmt.Sprintf("0 */%d * * * *", i) - } - } - parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if _, err := parser.Parse(schedule); err != nil { slog.WarnContext(ctx, "Invalid cron expression for vulnerability-scan, using default", "invalid_schedule", schedule, "error", err) diff --git a/backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql b/backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql new file mode 100644 index 0000000000..786df4a835 --- /dev/null +++ b/backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql @@ -0,0 +1,10 @@ +-- v2.0.0 rollback: recreate the apprise_settings table shape (data is not restored). +CREATE TABLE IF NOT EXISTS apprise_settings ( + id SERIAL PRIMARY KEY, + api_url TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + image_update_tag VARCHAR(255), + container_update_tag VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP +); diff --git a/backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql b/backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql new file mode 100644 index 0000000000..c3bbe402d2 --- /dev/null +++ b/backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql @@ -0,0 +1,12 @@ +-- v2.0.0: drop the Apprise notification service table and deprecated settings rows. +DROP TABLE IF EXISTS apprise_settings; + +DELETE FROM settings WHERE key IN ( + 'dockerPruneMode', + 'scheduledPruneContainers', + 'scheduledPruneImages', + 'scheduledPruneVolumes', + 'scheduledPruneNetworks', + 'scheduledPruneBuildCache', + 'authOidcConfig' +); diff --git a/backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql new file mode 100644 index 0000000000..4cffbb72a5 --- /dev/null +++ b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql @@ -0,0 +1,10 @@ +-- v2.0.0 rollback: recreate the apprise_settings table shape (data is not restored). +CREATE TABLE IF NOT EXISTS apprise_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_url TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + image_update_tag VARCHAR(255), + container_update_tag VARCHAR(255), + created_at DATETIME, + updated_at DATETIME +); diff --git a/backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql new file mode 100644 index 0000000000..c3bbe402d2 --- /dev/null +++ b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql @@ -0,0 +1,12 @@ +-- v2.0.0: drop the Apprise notification service table and deprecated settings rows. +DROP TABLE IF EXISTS apprise_settings; + +DELETE FROM settings WHERE key IN ( + 'dockerPruneMode', + 'scheduledPruneContainers', + 'scheduledPruneImages', + 'scheduledPruneVolumes', + 'scheduledPruneNetworks', + 'scheduledPruneBuildCache', + 'authOidcConfig' +); diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 738604833d..67d6582b58 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -68,36 +68,9 @@ func normalizeConfig(cfg *types.Config) *types.Config { return DefaultConfig() } - if normalized.Pagination.Default.Limit <= 0 && normalized.DefaultLimit > 0 { - normalized.Pagination.Default.Limit = normalized.DefaultLimit - } - if normalized.DefaultLimit <= 0 && normalized.Pagination.Default.Limit > 0 { - normalized.DefaultLimit = normalized.Pagination.Default.Limit - } - if normalized.Pagination.Resources == nil { normalized.Pagination.Resources = make(map[string]types.PaginationResourceConfig) } - if normalized.ResourceLimits == nil { - normalized.ResourceLimits = make(map[string]int) - } - - for resource, limit := range normalized.ResourceLimits { - resource = types.NormalizePaginatedResource(resource) - if resource == "" || limit <= 0 { - continue - } - if _, exists := normalized.Pagination.Resources[resource]; !exists { - normalized.Pagination.Resources[resource] = types.PaginationResourceConfig{Limit: limit} - } - } - for resource, cfg := range normalized.Pagination.Resources { - resource = types.NormalizePaginatedResource(resource) - if resource == "" || cfg.Limit <= 0 { - continue - } - normalized.ResourceLimits[resource] = cfg.Limit - } return normalized } @@ -278,24 +251,6 @@ func Save(c *types.Config) error { v.Set(fmt.Sprintf("pagination.resources.%s.limit", resource), rc.Limit) } - // Legacy keys retained for backward compatibility. - if cfg.DefaultLimit > 0 { - v.Set("default_limit", cfg.DefaultLimit) - } - if len(cfg.ResourceLimits) > 0 { - legacy := make(map[string]int) - for resource, limit := range cfg.ResourceLimits { - resource = types.NormalizePaginatedResource(resource) - if resource == "" || limit <= 0 { - continue - } - legacy[resource] = limit - } - if len(legacy) > 0 { - v.Set("resource_limits", legacy) - } - } - if err := v.WriteConfigAs(path); err != nil { return fmt.Errorf("failed to write config file: %w", err) } @@ -353,13 +308,6 @@ func InitDefaultFile() (bool, error) { v.Set(fmt.Sprintf("pagination.resources.%s.limit", resource), defaultPaginationInitLimit) } - v.Set("default_limit", defaultPaginationInitLimit) - legacy := make(map[string]int, len(types.KnownPaginatedResources)) - for _, resource := range types.KnownPaginatedResources { - legacy[resource] = defaultPaginationInitLimit - } - v.Set("resource_limits", legacy) - if err := v.WriteConfigAs(path); err != nil { return false, fmt.Errorf("failed to write config file: %w", err) } diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 2af0f6e1a9..723ebec8a6 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -53,7 +53,7 @@ func TestLoadReturnsDefaultsWhenFileMissing(t *testing.T) { } } -func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { +func TestSaveAndLoadRoundTripPagination(t *testing.T) { path := setTempConfigPath(t) cfg := DefaultConfig() @@ -75,12 +75,6 @@ func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { if !strings.Contains(text, "pagination:") { t.Fatalf("expected saved YAML to include pagination block:\n%s", text) } - if !strings.Contains(text, "default_limit: 42") { - t.Fatalf("expected saved YAML to include legacy default_limit key:\n%s", text) - } - if !strings.Contains(text, "resource_limits:") { - t.Fatalf("expected saved YAML to include legacy resource_limits key:\n%s", text) - } if !strings.Contains(text, "cli_update_channel: next") { t.Fatalf("expected saved YAML to include cli_update_channel key:\n%s", text) } @@ -97,8 +91,8 @@ func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { if err != nil { t.Fatalf("Load() failed: %v", err) } - if loaded.Pagination.Default.Limit != 42 || loaded.DefaultLimit != 42 { - t.Fatalf("default limit mismatch: pagination=%d legacy=%d", loaded.Pagination.Default.Limit, loaded.DefaultLimit) + if loaded.Pagination.Default.Limit != 42 { + t.Fatalf("default limit mismatch: pagination=%d, want 42", loaded.Pagination.Default.Limit) } if got := loaded.LimitFor("images"); got != 17 { t.Fatalf("images limit=%d, want 17", got) @@ -111,39 +105,6 @@ func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { } } -func TestLoadLegacyPaginationKeys(t *testing.T) { - path := setTempConfigPath(t) - content := ` -server_url: https://arcane.example -default_environment: "7" -log_level: warn -default_limit: 25 -resource_limits: - image: 31 - Volumes: 5 -` - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("failed to write config fixture: %v", err) - } - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - if cfg.ServerURL != "https://arcane.example" { - t.Fatalf("ServerURL=%q, want %q", cfg.ServerURL, "https://arcane.example") - } - if cfg.Pagination.Default.Limit != 25 || cfg.DefaultLimit != 25 { - t.Fatalf("default limit mismatch: pagination=%d legacy=%d", cfg.Pagination.Default.Limit, cfg.DefaultLimit) - } - if got := cfg.LimitFor("images"); got != 31 { - t.Fatalf("images limit=%d, want 31", got) - } - if got := cfg.LimitFor("volumes"); got != 5 { - t.Fatalf("volumes limit=%d, want 5", got) - } -} - func TestLoadCanonicalPaginationBlock(t *testing.T) { path := setTempConfigPath(t) content := ` @@ -210,8 +171,6 @@ func TestInitDefaultFileCreatesTemplate(t *testing.T) { "default_environment:", "log_level:", "pagination:", - "default_limit:", - "resource_limits:", } for _, key := range requiredKeys { if !strings.Contains(text, key) { @@ -231,9 +190,6 @@ func TestInitDefaultFileCreatesTemplate(t *testing.T) { if cfg.Pagination.Default.Limit != defaultPaginationInitLimit { t.Fatalf("Pagination.Default.Limit=%d, want %d", cfg.Pagination.Default.Limit, defaultPaginationInitLimit) } - if cfg.DefaultLimit != defaultPaginationInitLimit { - t.Fatalf("DefaultLimit=%d, want %d", cfg.DefaultLimit, defaultPaginationInitLimit) - } for _, resource := range []string{"containers", "images", "volumes", "networks", "projects", "environments", "registries", "templates", "users", "events", "apikeys"} { if got := cfg.LimitFor(resource); got != defaultPaginationInitLimit { t.Fatalf("LimitFor(%s)=%d, want %d", resource, got, defaultPaginationInitLimit) diff --git a/cli/internal/integrationtest/projects_json_compat_test.go b/cli/internal/integrationtest/projects_json_compat_test.go deleted file mode 100644 index 0614a8f99f..0000000000 --- a/cli/internal/integrationtest/projects_json_compat_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package integrationtest - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestProjectsJSONCompatibilityForComposeUnitBytes(t *testing.T) { - projectResponse := `{ - "success": true, - "data": { - "id": "project-1", - "name": "myproject", - "path": "/tmp/myproject", - "status": "running", - "serviceCount": 1, - "runningCount": 1, - "createdAt": "2026-03-13T00:00:00Z", - "updatedAt": "2026-03-13T00:00:00Z", - "services": [ - { - "name": "myapp", - "image": "nginx:latest", - "mem_limit": "256m", - "build": { - "context": ".", - "shm_size": "64m" - }, - "deploy": { - "resources": { - "limits": { - "memory": "512m" - } - } - } - } - ], - "runtimeServices": [ - { - "name": "myapp", - "image": "nginx:latest", - "status": "running", - "serviceConfig": { - "name": "myapp", - "image": "nginx:latest", - "mem_limit": "256m" - } - } - ] - } - }` - - downCalled := false - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/environments/0/projects/myproject": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(projectResponse)) - case r.Method == http.MethodPost && r.URL.Path == "/api/environments/0/projects/project-1/down": - downCalled = true - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"data":{"message":"stopped"}}`)) - default: - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"success":false,"error":"not found"}`)) - } - })) - defer srv.Close() - - configPath := writeCLIIntegrationConfigInternal(t, srv.URL) - - getStdout, getStderr, err := executeCLIIntegrationCommandInternal( - t, - []string{"--config", configPath, "projects", "get", "myproject", "--json"}, - ) - if err != nil { - t.Fatalf("projects get failed: %v (%s)", err, getStderr) - } - - var decoded map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(getStdout)), &decoded); err != nil { - t.Fatalf("projects get produced invalid JSON: %v\noutput=%s", err, getStdout) - } - - services, ok := decoded["services"].([]any) - if !ok || len(services) != 1 { - t.Fatalf("expected one service in JSON output, got %#v", decoded["services"]) - } - - serviceMap, ok := services[0].(map[string]any) - if !ok { - t.Fatalf("expected service entry to be a map, got %#v", services[0]) - } - - if got := serviceMap["mem_limit"]; got != "268435456" { - t.Fatalf("expected mem_limit to round-trip as normalized bytes, got %#v", got) - } - - _, downStderr, err := executeCLIIntegrationCommandInternal( - t, - []string{"--config", configPath, "projects", "down", "myproject"}, - ) - if err != nil { - t.Fatalf("projects down failed: %v (%s)", err, downStderr) - } - if !downCalled { - t.Fatal("expected projects down to issue a POST after resolving the project") - } -} diff --git a/cli/internal/types/config.go b/cli/internal/types/config.go index 7debc3c6a7..589d63bee4 100644 --- a/cli/internal/types/config.go +++ b/cli/internal/types/config.go @@ -94,10 +94,6 @@ type Config struct { CLIUpdateChannel string `yaml:"cli_update_channel,omitempty" mapstructure:"cli_update_channel"` // Pagination contains global and per-resource pagination configuration. Pagination PaginationConfig `yaml:"pagination,omitempty" mapstructure:"pagination"` - // DefaultLimit is a legacy global default list limit for paginated resources. - DefaultLimit int `yaml:"default_limit,omitempty" mapstructure:"default_limit"` - // ResourceLimits is a legacy map of per-resource list limits. - ResourceLimits map[string]int `yaml:"resource_limits,omitempty" mapstructure:"resource_limits"` } // HasAuth returns true if either an API key or JWT token is configured. @@ -140,10 +136,6 @@ func (c *Config) Clone() *Config { return nil } out := *c - if c.ResourceLimits != nil { - out.ResourceLimits = make(map[string]int, len(c.ResourceLimits)) - maps.Copy(out.ResourceLimits, c.ResourceLimits) - } if c.Pagination.Resources != nil { out.Pagination.Resources = make(map[string]PaginationResourceConfig, len(c.Pagination.Resources)) maps.Copy(out.Pagination.Resources, c.Pagination.Resources) @@ -151,7 +143,7 @@ func (c *Config) Clone() *Config { return &out } -// LimitFor returns the configured limit for a resource, falling back to DefaultLimit. +// LimitFor returns the configured limit for a resource, falling back to the global default. func (c *Config) LimitFor(resource string) int { if c == nil { return 0 @@ -165,16 +157,6 @@ func (c *Config) LimitFor(resource string) int { if c.Pagination.Default.Limit > 0 { return c.Pagination.Default.Limit } - - // Backward-compatibility with legacy keys. - if resource != "" && c.ResourceLimits != nil { - if v, ok := c.ResourceLimits[resource]; ok && v > 0 { - return v - } - } - if c.DefaultLimit > 0 { - return c.DefaultLimit - } return 0 } @@ -184,8 +166,6 @@ func (c *Config) SetDefaultLimit(limit int) { return } c.Pagination.Default.Limit = limit - // Keep legacy field in sync for compatibility. - c.DefaultLimit = limit } // SetResourceLimit configures per-resource pagination defaults. @@ -200,15 +180,9 @@ func (c *Config) SetResourceLimit(resource string, limit int) { if c.Pagination.Resources == nil { c.Pagination.Resources = make(map[string]PaginationResourceConfig) } - if c.ResourceLimits == nil { - c.ResourceLimits = make(map[string]int) - } if limit <= 0 { delete(c.Pagination.Resources, resource) - delete(c.ResourceLimits, resource) return } c.Pagination.Resources[resource] = PaginationResourceConfig{Limit: limit} - // Keep legacy field in sync for compatibility. - c.ResourceLimits[resource] = limit } diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index 91d8f1418d..a8b2730840 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -106,8 +106,6 @@ type ArcaneApiEndpoints struct { SettingsPublicEndpoint string // Notifications - NotificationsAppriseEndpoint string - NotificationsAppriseTestEndpoint string NotificationsSettingsEndpoint string NotificationSettingsProviderEndpoint string NotificationsTestProviderEndpoint string @@ -269,8 +267,6 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut SettingsPublicEndpoint: "/api/environments/%s/settings/public", // Notifications - NotificationsAppriseEndpoint: "/api/environments/%s/notifications/apprise", - NotificationsAppriseTestEndpoint: "/api/environments/%s/notifications/apprise/test", NotificationsSettingsEndpoint: "/api/environments/%s/notifications/settings", NotificationSettingsProviderEndpoint: "/api/environments/%s/notifications/settings/%s", NotificationsTestProviderEndpoint: "/api/environments/%s/notifications/test/%s", @@ -580,14 +576,6 @@ func (e ArcaneApiEndpoints) SettingsPublic(envID string) string { } // Notification endpoints -func (e ArcaneApiEndpoints) NotificationsApprise(envID string) string { - return fmt.Sprintf(e.NotificationsAppriseEndpoint, envID) -} - -func (e ArcaneApiEndpoints) NotificationsAppriseTest(envID string) string { - return fmt.Sprintf(e.NotificationsAppriseTestEndpoint, envID) -} - func (e ArcaneApiEndpoints) NotificationsSettings(envID string) string { return fmt.Sprintf(e.NotificationsSettingsEndpoint, envID) } diff --git a/cli/pkg/admin/notifications/cmd.go b/cli/pkg/admin/notifications/cmd.go index 78f8df9c5b..16548140f6 100644 --- a/cli/pkg/admin/notifications/cmd.go +++ b/cli/pkg/admin/notifications/cmd.go @@ -25,56 +25,11 @@ var NotificationsCmd = &cobra.Command{ Short: "Manage notifications", } -var appriseCmd = &cobra.Command{ - Use: "apprise", - Short: "Manage Apprise configuration", -} - var settingsCmd = &cobra.Command{ Use: "settings", Short: "Manage notification settings", } -var appriseGetCmd = &cobra.Command{ - Use: "get", - Short: "Get Apprise configuration", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.NotificationsApprise(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get apprise config: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[notification.AppriseResponse] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Header("Apprise Configuration") - output.KeyValue("ID", fmt.Sprintf("%d", result.Data.ID)) - output.KeyValue("API URL", result.Data.APIURL) - output.KeyValue("Enabled", fmt.Sprintf("%t", result.Data.Enabled)) - output.KeyValue("Image Update Tag", result.Data.ImageUpdateTag) - output.KeyValue("Container Update Tag", result.Data.ContainerUpdateTag) - return nil - }, -} - var settingsGetCmd = &cobra.Command{ Use: "get", Short: "Get notification settings", @@ -121,40 +76,6 @@ var settingsGetCmd = &cobra.Command{ }, } -var appriseTestCmd = &cobra.Command{ - Use: "test", - Short: "Test Apprise notification", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.NotificationsAppriseTest(c.EnvID()), nil) - if err != nil { - return fmt.Errorf("failed to test apprise: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("apprise test failed: %w", err) - } - - if jsonOutput { - var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { - if resultBytes, err := json.MarshalIndent(result.Data, "", " "); err == nil { - fmt.Println(string(resultBytes)) - } - } - return nil - } - - output.Success("Apprise notification test successful") - return nil - }, -} - var settingsDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete notification provider settings", @@ -227,18 +148,12 @@ var testProviderCmd = &cobra.Command{ } func init() { - NotificationsCmd.AddCommand(appriseCmd) NotificationsCmd.AddCommand(settingsCmd) NotificationsCmd.AddCommand(testProviderCmd) - appriseCmd.AddCommand(appriseGetCmd) - appriseCmd.AddCommand(appriseTestCmd) - settingsCmd.AddCommand(settingsGetCmd) settingsCmd.AddCommand(settingsDeleteCmd) - appriseGetCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") - appriseTestCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") settingsGetCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") settingsDeleteCmd.Flags().BoolVarP(¬ifForceFlag, "force", "f", false, "Force deletion without confirmation") settingsDeleteCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") diff --git a/cli/pkg/config/cmd.go b/cli/pkg/config/cmd.go index f84d747a42..bf2a33103c 100644 --- a/cli/pkg/config/cmd.go +++ b/cli/pkg/config/cmd.go @@ -49,11 +49,7 @@ var configShowCmd = &cobra.Command{ fmt.Printf("Default Environment: %s\n", maskIfEmpty(cfg.DefaultEnvironment, "0 (local)")) fmt.Printf("Log Level: %s\n", maskIfEmpty(cfg.LogLevel, "info (default)")) fmt.Printf("CLI Update Channel: %s\n", maskIfEmpty(cfg.CLIUpdateChannel, "(auto)")) - globalLimit := cfg.Pagination.Default.Limit - if globalLimit <= 0 { - globalLimit = cfg.DefaultLimit - } - fmt.Printf("Pagination Default: %s\n", maskIfEmpty(intToString(globalLimit), "(not set)")) + fmt.Printf("Pagination Default: %s\n", maskIfEmpty(intToString(cfg.Pagination.Default.Limit), "(not set)")) fmt.Println("\nPagination Resources:") printed := 0 diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a143749ad5..cbb073dc09 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2172,7 +2172,6 @@ "notifications_title": "Notifications", "notifications_description": "Configure notifications for container updates", "notifications_tab_built_in": "Built-in Notifications", - "notifications_tab_apprise": "Apprise", "notifications_read_only_title": "Read-only Mode", "notifications_read_only_description": "Settings are read-only in this environment. Configuration changes are disabled.", "notifications_discord_title": "Discord", @@ -2242,18 +2241,6 @@ "notifications_test_vulnerability_notification": "Test Vulnerability Notification", "notifications_test_prune_report_notification": "Test Prune Report Notification", "notifications_test_auto_heal_notification": "Test Auto-Heal Notification", - "notifications_apprise_title": "Apprise Integration", - "notifications_apprise_description": "Configure Apprise API for unified notifications across multiple services", - "notifications_apprise_enabled_label": "Enable Apprise", - "notifications_apprise_api_url_label": "Apprise API URL", - "notifications_apprise_api_url_placeholder": "http://localhost:8000/notify", - "notifications_apprise_api_url_help": "URL of your Apprise API endpoint", - "notifications_apprise_image_tag_label": "Image Update Tag", - "notifications_apprise_image_tag_placeholder": "image-updates", - "notifications_apprise_image_tag_help": "Optional tag for image update notifications", - "notifications_apprise_container_tag_label": "Container Update Tag", - "notifications_apprise_container_tag_placeholder": "container-updates", - "notifications_apprise_container_tag_help": "Optional tag for container update notifications", "notifications_signal_title": "Signal Notifications", "notifications_signal_description": "Send notifications via Signal Messenger through a Signal API server", "notifications_signal_enabled_label": "Enable Signal Notifications", diff --git a/frontend/src/lib/components/action-buttons.svelte b/frontend/src/lib/components/action-buttons.svelte index 225b113078..c90836b8b0 100644 --- a/frontend/src/lib/components/action-buttons.svelte +++ b/frontend/src/lib/components/action-buttons.svelte @@ -235,22 +235,11 @@ update(); - if ('addEventListener' in mqlXl) { - mqlXl.addEventListener('change', update); - mqlLg.addEventListener('change', update); - return () => { - mqlXl.removeEventListener('change', update); - mqlLg.removeEventListener('change', update); - }; - } - - // @ts-expect-error legacy MediaQueryList API - mqlXl.addListener(update); - mqlLg.addListener(update); + mqlXl.addEventListener('change', update); + mqlLg.addEventListener('change', update); return () => { - // @ts-expect-error legacy MediaQueryList API - mqlXl.removeListener(update); - mqlLg.removeListener(update); + mqlXl.removeEventListener('change', update); + mqlLg.removeEventListener('change', update); }; }); diff --git a/frontend/src/lib/services/notification-service.ts b/frontend/src/lib/services/notification-service.ts index 4addd2d851..d40f8acade 100644 --- a/frontend/src/lib/services/notification-service.ts +++ b/frontend/src/lib/services/notification-service.ts @@ -1,5 +1,5 @@ import BaseAPIService from './api-service'; -import type { NotificationSettings, TestNotificationResponse, AppriseSettings } from '$lib/types/notification.type'; +import type { NotificationSettings, TestNotificationResponse } from '$lib/types/notification.type'; import { environmentStore } from '$lib/stores/environment.store.svelte'; export default class NotificationService extends BaseAPIService { @@ -22,23 +22,6 @@ export default class NotificationService extends BaseAPIService { const encodedType = encodeURIComponent(type); return this.handleResponse(this.api.post(`/environments/${envId}/notifications/test/${provider}?type=${encodedType}`)); } - - async getAppriseSettings(environmentId?: string): Promise { - const envId = environmentId || (await environmentStore.getCurrentEnvironmentId()); - const res = await this.api.get(`/environments/${envId}/notifications/apprise`); - return res.data; - } - - async updateAppriseSettings(settings: AppriseSettings): Promise { - const envId = await environmentStore.getCurrentEnvironmentId(); - const res = await this.api.post(`/environments/${envId}/notifications/apprise`, settings); - return res.data; - } - - async testAppriseNotification(type: string = 'simple'): Promise { - const envId = await environmentStore.getCurrentEnvironmentId(); - return this.handleResponse(this.api.post(`/environments/${envId}/notifications/apprise/test?type=${type}`)); - } } export const notificationService = new NotificationService(); diff --git a/frontend/src/lib/types/container.type.ts b/frontend/src/lib/types/container.type.ts index 310b122174..0e99f86d95 100644 --- a/frontend/src/lib/types/container.type.ts +++ b/frontend/src/lib/types/container.type.ts @@ -140,14 +140,9 @@ export interface ContainerStatusCounts { export interface ContainerHealthLogEntry { start?: string; - // Legacy PascalCase, kept for backwards-compat with older payloads. - Start?: string; end?: string; - End?: string; exitCode?: number; - ExitCode?: number; output?: string; - Output?: string; } export interface ContainerHealthDto { diff --git a/frontend/src/lib/types/notification-providers.ts b/frontend/src/lib/types/notification-providers.ts index 7f57e5b2b9..1ee01d2817 100644 --- a/frontend/src/lib/types/notification-providers.ts +++ b/frontend/src/lib/types/notification-providers.ts @@ -1,4 +1,4 @@ -import type { NotificationSettings, AppriseSettings, EmailTLSMode, EmailAuthMode } from './notification.type'; +import type { NotificationSettings, EmailTLSMode, EmailAuthMode } from './notification.type'; // Provider keys - this is the source of truth for all providers (alphabetically sorted) export const NOTIFICATION_PROVIDER_KEYS = [ @@ -124,13 +124,6 @@ export interface GenericFormValues extends BaseProviderFormValues { customHeaders: string; } -export interface AppriseFormValues { - enabled: boolean; - apiUrl: string; - imageUpdateTag: string; - containerUpdateTag: string; -} - // Map provider keys to their form value types export type ProviderFormValuesMap = { discord: DiscordFormValues; @@ -279,15 +272,6 @@ export function slackSettingsToFormValues(settings?: NotificationSettings): Slac }; } -export function appriseSettingsToFormValues(settings?: AppriseSettings): AppriseFormValues { - return { - enabled: settings?.enabled ?? false, - apiUrl: settings?.apiUrl || '', - imageUpdateTag: settings?.imageUpdateTag || '', - containerUpdateTag: settings?.containerUpdateTag || '' - }; -} - // Helper to convert form values back to API format export function discordFormValuesToSettings(values: DiscordFormValues): NotificationSettings { return { @@ -634,12 +618,3 @@ export function genericFormValuesToSettings(values: GenericFormValues): Notifica } }; } - -export function appriseFormValuesToSettings(values: AppriseFormValues): AppriseSettings { - return { - enabled: values.enabled, - apiUrl: values.apiUrl, - imageUpdateTag: values.imageUpdateTag, - containerUpdateTag: values.containerUpdateTag - }; -} diff --git a/frontend/src/lib/types/notification.type.ts b/frontend/src/lib/types/notification.type.ts index 4928436a5b..7bcc35a1e0 100644 --- a/frontend/src/lib/types/notification.type.ts +++ b/frontend/src/lib/types/notification.type.ts @@ -18,14 +18,6 @@ export interface NotificationSettings { config?: Record; } -export interface AppriseSettings { - id?: number; - apiUrl: string; - enabled: boolean; - imageUpdateTag: string; - containerUpdateTag: string; -} - export interface TestNotificationResponse { success: boolean; message?: string; diff --git a/frontend/src/routes/(app)/containers/components/ContainerHealthcheck.svelte b/frontend/src/routes/(app)/containers/components/ContainerHealthcheck.svelte index 52d4bd2259..9c67653561 100644 --- a/frontend/src/routes/(app)/containers/components/ContainerHealthcheck.svelte +++ b/frontend/src/routes/(app)/containers/components/ContainerHealthcheck.svelte @@ -38,15 +38,14 @@ return isNaN(d.getTime()) ? null : d; } - // Normalize log entry casing (backend now emits lowercase; tolerate older PascalCase). function normalizeLog(entries: ContainerHealthLogEntry[] | undefined) { if (!entries) return []; return entries .map((e) => ({ - start: parseDockerDate(e.start ?? e.Start), - end: parseDockerDate(e.end ?? e.End), - exitCode: (e.exitCode ?? e.ExitCode ?? 0) as number, - output: (e.output ?? e.Output ?? '') as string + start: parseDockerDate(e.start), + end: parseDockerDate(e.end), + exitCode: (e.exitCode ?? 0) as number, + output: (e.output ?? '') as string })) .filter((e) => e.start || e.end); } diff --git a/frontend/src/routes/(app)/images/builds/+page.svelte b/frontend/src/routes/(app)/images/builds/+page.svelte index 2f49df5a16..8c1ef8bb9c 100644 --- a/frontend/src/routes/(app)/images/builds/+page.svelte +++ b/frontend/src/routes/(app)/images/builds/+page.svelte @@ -431,17 +431,8 @@ update(); - if ('addEventListener' in mq) { - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); - } - - // @ts-expect-error legacy MediaQueryList API - mq.addListener(update); - return () => { - // @ts-expect-error legacy MediaQueryList API - mq.removeListener(update); - }; + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); }); $effect(() => { diff --git a/frontend/src/routes/(app)/settings/notifications/+page.svelte b/frontend/src/routes/(app)/settings/notifications/+page.svelte index d2c084c621..da86078881 100644 --- a/frontend/src/routes/(app)/settings/notifications/+page.svelte +++ b/frontend/src/routes/(app)/settings/notifications/+page.svelte @@ -1,5 +1,4 @@ - - - - - - diff --git a/frontend/src/routes/(app)/settings/notifications/providers/index.ts b/frontend/src/routes/(app)/settings/notifications/providers/index.ts index d508d18c4f..9a91f3d608 100644 --- a/frontend/src/routes/(app)/settings/notifications/providers/index.ts +++ b/frontend/src/routes/(app)/settings/notifications/providers/index.ts @@ -1,4 +1,3 @@ -export { default as AppriseProviderForm } from './AppriseProviderForm.svelte'; export { default as BuiltInProviderForm } from './BuiltInProviderForm.svelte'; export { default as ProviderFormWrapper } from './ProviderFormWrapper.svelte'; export { default as EventSubscriptions } from './EventSubscriptions.svelte'; diff --git a/tests/spec/cli.spec.ts b/tests/spec/cli.spec.ts index 2e244bf90a..85f678be83 100644 --- a/tests/spec/cli.spec.ts +++ b/tests/spec/cli.spec.ts @@ -189,13 +189,6 @@ const readOnlyJsonSmokeCommands: JsonSmokeCommand[] = [ args: ['admin', 'events', 'list-env', '--limit', '5', '--json'], expectation: expectPaginated }, - { - name: 'admin notifications apprise get', - args: ['admin', 'notifications', 'apprise', 'get', '--json'], - expectation: (value) => { - expect(value).toEqual(expect.objectContaining({ enabled: expect.any(Boolean) })); - } - }, { name: 'admin notifications settings get', args: ['admin', 'notifications', 'settings', 'get', '--json'], diff --git a/tests/spec/settings-notifications.spec.ts b/tests/spec/settings-notifications.spec.ts index 86c19baad6..e9810bf10d 100644 --- a/tests/spec/settings-notifications.spec.ts +++ b/tests/spec/settings-notifications.spec.ts @@ -65,19 +65,6 @@ test.describe('Notification settings', () => { await route.continue(); }); - await page.route('**/api/environments/*/notifications/apprise', async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ error: 'not configured' }) - }); - return; - } - - await route.continue(); - }); - // Stub the specific test endpoint await page.route(`**/api/environments/*/notifications/test/${provider}**`, async (route) => { testEndpointCalled = true; @@ -90,7 +77,6 @@ test.describe('Notification settings', () => { await page.goto('/settings/notifications'); await page.waitForLoadState('networkidle'); - await expect(page.getByRole('tab', { name: 'Built-in Notifications' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible(); return { diff --git a/types/environment/environment.go b/types/environment/environment.go index 033c61928b..6d1fa70904 100644 --- a/types/environment/environment.go +++ b/types/environment/environment.go @@ -23,11 +23,6 @@ type Create struct { // Required: false AccessToken *string `json:"accessToken,omitempty"` //nolint:gosec // API schema requires accessToken field name - // BootstrapToken for initial setup of the environment. - // - // Required: false - BootstrapToken *string `json:"bootstrapToken,omitempty"` - // UseApiKey indicates if an API key should be generated for pairing. // // Required: false @@ -60,11 +55,6 @@ type Update struct { // Required: false AccessToken *string `json:"accessToken,omitempty"` //nolint:gosec // API schema requires accessToken field name - // BootstrapToken for initial setup of the environment. - // - // Required: false - BootstrapToken *string `json:"bootstrapToken,omitempty"` - // RegenerateApiKey indicates whether to regenerate the API key. // // Required: false diff --git a/types/notification/notification.go b/types/notification/notification.go index fc5079050e..7e8d5c1cbf 100644 --- a/types/notification/notification.go +++ b/types/notification/notification.go @@ -77,55 +77,6 @@ type Response struct { Config base.JsonObject `json:"config"` } -type AppriseUpdate struct { - // APIURL is the URL of the Apprise API endpoint. - // - // Required: false - APIURL string `json:"apiUrl"` - - // Enabled indicates if Apprise is enabled. - // - // Required: true - Enabled bool `json:"enabled"` - - // ImageUpdateTag is the tag to use for image update notifications. - // - // Required: false - ImageUpdateTag string `json:"imageUpdateTag"` - - // ContainerUpdateTag is the tag to use for container update notifications. - // - // Required: false - ContainerUpdateTag string `json:"containerUpdateTag"` -} - -type AppriseResponse struct { - // ID is the unique identifier of the Apprise settings. - // - // Required: true - ID uint `json:"id"` - - // APIURL is the URL of the Apprise API endpoint. - // - // Required: false - APIURL string `json:"apiUrl"` - - // Enabled indicates if Apprise is enabled. - // - // Required: true - Enabled bool `json:"enabled"` - - // ImageUpdateTag is the tag to use for image update notifications. - // - // Required: false - ImageUpdateTag string `json:"imageUpdateTag"` - - // ContainerUpdateTag is the tag to use for container update notifications. - // - // Required: false - ContainerUpdateTag string `json:"containerUpdateTag"` -} - type DispatchKind string const ( diff --git a/types/settings/settings.go b/types/settings/settings.go index 50d3edbd18..317459789c 100644 --- a/types/settings/settings.go +++ b/types/settings/settings.go @@ -94,13 +94,6 @@ type Update struct { // Required: false EnvironmentHealthInterval *string `json:"environmentHealthInterval,omitempty"` - // PruneMode is the Docker prune mode ("all" or "dangling"). - // - // Deprecated: Use the granular prune mode settings instead. - // - // Required: false - PruneMode *string `json:"dockerPruneMode,omitempty" binding:"omitempty,oneof=all dangling"` - // DefaultDeployPullPolicy is the default image pull policy used for project deploys. // // Required: false @@ -116,41 +109,6 @@ type Update struct { // Required: false ScheduledPruneInterval *string `json:"scheduledPruneInterval,omitempty"` - // ScheduledPruneContainers indicates if stopped containers should be pruned. - // - // Deprecated: Use pruneContainerMode instead. - // - // Required: false - ScheduledPruneContainers *string `json:"scheduledPruneContainers,omitempty"` - - // ScheduledPruneImages indicates if unused images should be pruned. - // - // Deprecated: Use pruneImageMode instead. - // - // Required: false - ScheduledPruneImages *string `json:"scheduledPruneImages,omitempty"` - - // ScheduledPruneVolumes indicates if unused volumes should be pruned. - // - // Deprecated: Use pruneVolumeMode instead. - // - // Required: false - ScheduledPruneVolumes *string `json:"scheduledPruneVolumes,omitempty"` - - // ScheduledPruneNetworks indicates if unused networks should be pruned. - // - // Deprecated: Use pruneNetworkMode instead. - // - // Required: false - ScheduledPruneNetworks *string `json:"scheduledPruneNetworks,omitempty"` - - // ScheduledPruneBuildCache indicates if build cache should be pruned. - // - // Deprecated: Use pruneBuildCacheMode instead. - // - // Required: false - ScheduledPruneBuildCache *string `json:"scheduledPruneBuildCache,omitempty"` - // PruneContainerMode controls how containers are pruned during scheduled prune. // // Required: false @@ -334,11 +292,6 @@ type Update struct { // Required: false TrivyConcurrentScanContainers *string `json:"trivyConcurrentScanContainers,omitempty"` - // AuthOidcConfig is deprecated and will be removed in a future release. - // - // Required: false - AuthOidcConfig *string `json:"authOidcConfig,omitempty"` - // OidcClientId is the OIDC client identifier. // // Required: false From 157f3bb44e94bba9e73b8f246204917c92c386fb Mon Sep 17 00:00:00 2001 From: wrycu <4709746+wrycu@users.noreply.github.com> Date: Sat, 23 May 2026 20:32:13 -0700 Subject: [PATCH 02/40] feat: use DHI images for base docker image (#2499) Co-authored-by: Kyle Mendell --- .github/workflows/hadolint.yml | 23 ++- backend/internal/services/image_service.go | 22 +-- .../pkg/libarcane/startup/runtime_identity.go | 26 ++- .../startup/runtime_identity_test.go | 25 ++- docker/Dockerfile | 15 +- docker/Dockerfile-agent | 12 +- docker/examples/compose.basic.yaml | 9 +- docker/examples/compose.proxy.yaml | 6 +- tests/setup/compose-postgres.yaml | 3 +- tests/setup/compose-proxy.yaml | 3 +- tests/setup/compose.yaml | 3 +- tests/setup/global-setup.ts | 44 ++++- tests/setup/project.data.ts | 17 +- tests/spec/docker-runtime-identity.spec.ts | 160 +++++++++++++++--- tests/spec/project.spec.ts | 105 ++++++------ 15 files changed, 331 insertions(+), 142 deletions(-) diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index 028ef0dadf..d73ae5002b 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -10,12 +10,12 @@ name: Hadolint on: push: - branches: [ "main" ] + branches: ["main"] pull_request: # The branches below must be a subset of the branches above - branches: [ "main" ] + branches: ["main"] schedule: - - cron: '24 19 * * 2' + - cron: "24 19 * * 2" permissions: contents: read @@ -28,6 +28,16 @@ jobs: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + strategy: + fail-fast: false + matrix: + include: + - name: manager + dockerfile: ./docker/Dockerfile + output: hadolint-results-manager.sarif + - name: agent + dockerfile: ./docker/Dockerfile-agent + output: hadolint-results-agent.sarif steps: - name: Checkout code uses: actions/checkout@v6 @@ -35,13 +45,14 @@ jobs: - name: Run hadolint uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 with: - dockerfile: ./docker/Dockerfile + dockerfile: ${{ matrix.dockerfile }} format: sarif - output-file: hadolint-results.sarif + output-file: ${{ matrix.output }} no-fail: true - name: Upload analysis results to GitHub uses: github/codeql-action/upload-sarif@v4.36.0 with: - sarif_file: hadolint-results.sarif + sarif_file: ${{ matrix.output }} + category: hadolint-${{ matrix.name }} wait-for-processing: true diff --git a/backend/internal/services/image_service.go b/backend/internal/services/image_service.go index 6734c1cb0a..8a403ccfdf 100644 --- a/backend/internal/services/image_service.go +++ b/backend/internal/services/image_service.go @@ -570,11 +570,6 @@ func (s *ImageService) GetUpdateInfoByImageRefs(ctx context.Context, imageRefs [ } func (s *ImageService) ListImagesPaginated(ctx context.Context, params pagination.QueryParams) ([]imagetypes.Summary, pagination.Response, error) { - dockerClient, err := s.dockerService.GetClient(ctx) - if err != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to connect to Docker: %w", err) - } - var ( dockerImages []image.Summary containers []container.Summary @@ -586,22 +581,22 @@ func (s *ImageService) ListImagesPaginated(ctx context.Context, params paginatio // Fetch Docker images g.Go(func() error { var err error - imageList, err := dockerClient.ImageList(groupCtx, client.ImageListOptions{}) + imageList, err := s.dockerService.listImagesInternal(groupCtx) if err != nil { return fmt.Errorf("failed to list Docker images: %w", err) } - dockerImages = imageList.Items + dockerImages = imageList return nil }) // Fetch containers to determine usage g.Go(func() error { var err error - containerList, err := dockerClient.ContainerList(groupCtx, client.ContainerListOptions{All: true}) + containerList, err := s.dockerService.listContainersInternal(groupCtx) if err != nil { return fmt.Errorf("failed to list containers: %w", err) } - containers = containerList.Items + containers = containerList return nil }) @@ -744,18 +739,13 @@ func convertLabels(labels map[string]string) map[string]any { } func (s *ImageService) GetTotalImageSize(ctx context.Context) (int64, error) { - dockerClient, err := s.dockerService.GetClient(ctx) - if err != nil { - return 0, fmt.Errorf("failed to connect to Docker: %w", err) - } - - imageList, err := dockerClient.ImageList(ctx, client.ImageListOptions{}) + images, err := s.dockerService.listImagesInternal(ctx) if err != nil { return 0, fmt.Errorf("failed to list images: %w", err) } var total int64 - for _, img := range imageList.Items { + for _, img := range images { total += img.Size } diff --git a/backend/pkg/libarcane/startup/runtime_identity.go b/backend/pkg/libarcane/startup/runtime_identity.go index 1fa81b518d..d0315dbf7d 100644 --- a/backend/pkg/libarcane/startup/runtime_identity.go +++ b/backend/pkg/libarcane/startup/runtime_identity.go @@ -17,6 +17,8 @@ const ( defaultDatabaseURL = "file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" defaultDockerConfigDir = "/app/data/.docker" defaultDockerSocketPath = "/var/run/docker.sock" + defaultRuntimeUID = 65532 + defaultRuntimeGID = 65532 mountInfoPath = "/proc/self/mountinfo" ) @@ -65,8 +67,7 @@ func ApplyRequestedRuntimeIdentity(ctx context.Context, cfg *RuntimeIdentityConf runtimeUID := req.UID runtimeGID := req.GID - // Avoid re-execing forever when the requested runtime identity is already active, - // including explicit root requests such as PUID=0/PGID=0. + // Avoid re-execing forever when the requested runtime identity is already active. if os.Geteuid() == runtimeUID && os.Getegid() == runtimeGID { if err := ensureRuntimeDockerConfigInternal(cfg, os.Setenv, runtimeUID, runtimeGID); err != nil { return err @@ -108,11 +109,11 @@ func loadRuntimeIdentityRequestInternal(cfg *RuntimeIdentityConfig) (runtimeIden pgid := strings.TrimSpace(cfg.PGID) if puid == "" && pgid == "" { - return runtimeIdentityRequest{}, "", nil + return defaultRuntimeIdentityRequestInternal(cfg.DockerHost), "", nil } if puid == "" || pgid == "" { - return runtimeIdentityRequest{}, "PUID and PGID must both be set to enable non-root mode; continuing with default runtime user", nil + return defaultRuntimeIdentityRequestInternal(cfg.DockerHost), "PUID and PGID must both be set to override the default non-root runtime user; continuing with the default non-root runtime user", nil } uid, credentialUID, err := parseRuntimeIdentityValueInternal(puid, "PUID") @@ -135,6 +136,17 @@ func loadRuntimeIdentityRequestInternal(cfg *RuntimeIdentityConfig) (runtimeIden }, "", nil } +func defaultRuntimeIdentityRequestInternal(dockerHost string) runtimeIdentityRequest { + return runtimeIdentityRequest{ + Enabled: true, + UID: defaultRuntimeUID, + GID: defaultRuntimeGID, + CredentialUID: uint32(defaultRuntimeUID), + CredentialGID: uint32(defaultRuntimeGID), + DockerHost: dockerHost, + } +} + func runtimeDockerConfigDirInternal(cfg *RuntimeIdentityConfig) string { if cfg == nil { cfg = &RuntimeIdentityConfig{} @@ -257,6 +269,9 @@ func prepareWritablePathsWithRootsInternal(uid int, gid int, mountpoints map[str for _, entry := range entries { entryPath := filepath.Join(dataDirectory, entry.Name()) if _, mounted := mountpoints[entryPath]; mounted { + if err := os.Lchown(entryPath, uid, gid); err != nil { + return fmt.Errorf("chown mounted %s: %w", entryPath, err) + } continue } if projectsDir != "" && filepath.Clean(entryPath) == projectsDir { @@ -271,6 +286,9 @@ func prepareWritablePathsWithRootsInternal(uid int, gid int, mountpoints map[str } if _, mounted := mountpoints[buildsDirectory]; mounted { + if err := os.Lchown(buildsDirectory, uid, gid); err != nil { + return fmt.Errorf("chown mounted builds directory: %w", err) + } return nil } diff --git a/backend/pkg/libarcane/startup/runtime_identity_test.go b/backend/pkg/libarcane/startup/runtime_identity_test.go index 8b26d43ca7..cf64b7e24d 100644 --- a/backend/pkg/libarcane/startup/runtime_identity_test.go +++ b/backend/pkg/libarcane/startup/runtime_identity_test.go @@ -11,18 +11,29 @@ import ( ) func TestLoadRuntimeIdentityRequest(t *testing.T) { - t.Run("disabled when unset", func(t *testing.T) { - req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{}) + t.Run("enabled with default non-root runtime when unset", func(t *testing.T) { + req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{ + DockerHost: "unix:///tmp/docker.sock", + }) require.NoError(t, err) require.Empty(t, warning) - require.False(t, req.Enabled) + require.True(t, req.Enabled) + require.Equal(t, defaultRuntimeUID, req.UID) + require.Equal(t, defaultRuntimeGID, req.GID) + require.Equal(t, uint32(defaultRuntimeUID), req.CredentialUID) + require.Equal(t, uint32(defaultRuntimeGID), req.CredentialGID) + require.Equal(t, "unix:///tmp/docker.sock", req.DockerHost) }) t.Run("warning when partial config", func(t *testing.T) { req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{PUID: "1001"}) require.NoError(t, err) require.Contains(t, warning, "PUID and PGID must both be set") - require.False(t, req.Enabled) + require.True(t, req.Enabled) + require.Equal(t, defaultRuntimeUID, req.UID) + require.Equal(t, defaultRuntimeGID, req.GID) + require.Equal(t, uint32(defaultRuntimeUID), req.CredentialUID) + require.Equal(t, uint32(defaultRuntimeGID), req.CredentialGID) }) t.Run("error when invalid numeric value", func(t *testing.T) { @@ -75,12 +86,14 @@ func TestRuntimeDockerConfigDir(t *testing.T) { require.Equal(t, "/custom/docker-config", dir) }) - t.Run("root default runtime leaves runtime identity disabled", func(t *testing.T) { + t.Run("default runtime uses non-root identity", func(t *testing.T) { req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{}) require.NoError(t, err) require.Empty(t, warning) - require.False(t, req.Enabled) + require.True(t, req.Enabled) + require.Equal(t, defaultRuntimeUID, req.UID) + require.Equal(t, defaultRuntimeGID, req.GID) }) } diff --git a/docker/Dockerfile b/docker/Dockerfile index b4fc058bdb..93bb215a73 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 ARG BUILD_TAGS="" +ARG ARCANE_RUNTIME_BASE_IMAGE=ghcr.io/getarcaneapp/base:trixie # Stage 1: Build Frontend FROM --platform=$BUILDPLATFORM node:25-trixie-slim AS frontend-builder @@ -64,16 +65,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ -o /build/arcane \ ./cmd/main.go -# Stage 3: Production Image -FROM debian:trixie-slim AS runner +# Stage 3: Hardened Production Image +FROM ${ARCANE_RUNTIME_BASE_IMAGE} AS runner-hardened + ARG TARGETARCH ARG VERSION ARG REVISION -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl tzdata tar gzip libdrm2 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - ENV GIN_MODE=release ENV PORT=3552 ENV ENVIRONMENT=production @@ -86,7 +84,10 @@ ENV NVIDIA_VISIBLE_DEVICES=all \ WORKDIR /app -RUN mkdir -p /app/data /builds + +USER 0:0 +RUN mkdir -p /app/data /builds \ + && chown -R 65532:65532 /app /builds COPY --from=backend-builder /build/arcane . EXPOSE 3552 VOLUME ["/app/data"] diff --git a/docker/Dockerfile-agent b/docker/Dockerfile-agent index 2737df7373..12b2bbcdf2 100644 --- a/docker/Dockerfile-agent +++ b/docker/Dockerfile-agent @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 ARG VERSION="dev" ARG REVISION="unknown" +ARG ARCANE_RUNTIME_BASE_IMAGE=ghcr.io/getarcaneapp/base:trixie FROM --platform=$BUILDPLATFORM golang:1.26.3-trixie AS agent-builder ARG TARGETARCH @@ -43,14 +44,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ -o /out/arcane-agent \ ./cmd/main.go -FROM debian:trixie-slim AS agent +FROM ${ARCANE_RUNTIME_BASE_IMAGE} AS agent ARG TARGETARCH -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl tzdata tar gzip libdrm2 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app -RUN mkdir -p /app/data +USER 0:0 +RUN mkdir -p /app/data \ + && chown -R 65532:65532 /app COPY --from=agent-builder /out/arcane-agent ./arcane-agent @@ -74,4 +74,4 @@ VOLUME ["/app/data"] LABEL com.getarcaneapp.arcane.agent="true" -CMD ["./arcane-agent"] \ No newline at end of file +CMD ["./arcane-agent"] diff --git a/docker/examples/compose.basic.yaml b/docker/examples/compose.basic.yaml index 8b84d82c07..48a8c85d16 100644 --- a/docker/examples/compose.basic.yaml +++ b/docker/examples/compose.basic.yaml @@ -7,11 +7,12 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - arcane-data:/app/data - - /host/path/to/projects:/app/data/projects + - /host/path/to/projects:/host/path/to/projects - /host/path/to/builds:/builds environment: - ENCRYPTION_KEY=xxxxxxxxxxxxxxxxxxxxxx - JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxx + - PROJECTS_DIRECTORY=/host/path/to/projects # Optional: run Arcane as a specific host UID/GID instead of the default runtime user. # When using the mounted Docker socket, Arcane will map the socket group automatically. # - PUID=1000 @@ -25,8 +26,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "curl -fsS http://localhost:3552/api/health >/dev/null || exit 1", + "CMD", + "curl", + "-fsS", + "http://localhost:3552/api/health", ] interval: 10s timeout: 3s diff --git a/docker/examples/compose.proxy.yaml b/docker/examples/compose.proxy.yaml index 8c5793c11d..274798e51a 100644 --- a/docker/examples/compose.proxy.yaml +++ b/docker/examples/compose.proxy.yaml @@ -59,8 +59,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "curl -fsS http://localhost:3552/api/health >/dev/null || exit 1", + "CMD", + "curl", + "-fsS", + "http://localhost:3552/api/health", ] interval: 10s timeout: 3s diff --git a/tests/setup/compose-postgres.yaml b/tests/setup/compose-postgres.yaml index a5c7503d10..678a528724 100644 --- a/tests/setup/compose-postgres.yaml +++ b/tests/setup/compose-postgres.yaml @@ -15,12 +15,13 @@ services: arcane: image: arcane:playwright-tests - user: root ports: - '3001:3552' environment: - ENVIRONMENT=testing - APP_ENV=test + - PUID=65532 + - PGID=65532 - DATABASE_URL=postgres://arcane:arcane_test_password@postgres:5432/arcane_test?sslmode=disable - ENCRYPTION_KEY=3JDIgolks2tJ9ymm1AdqzlYMWu0DUWyt - JWT_SECRET=your-super-secret-jwt-key-change-this-in-production diff --git a/tests/setup/compose-proxy.yaml b/tests/setup/compose-proxy.yaml index dfdcfba1ee..ed18bf0ec3 100644 --- a/tests/setup/compose-proxy.yaml +++ b/tests/setup/compose-proxy.yaml @@ -32,12 +32,13 @@ services: arcane: image: arcane:playwright-tests - user: root ports: - '3002:3552' environment: - ENVIRONMENT=testing - APP_ENV=test + - PUID=65532 + - PGID=65532 - ENCRYPTION_KEY=3JDIgolks2tJ9ymm1AdqzlYMWu0DUWyt - JWT_SECRET=your-super-secret-jwt-key-change-this-in-production - ADMIN_STATIC_API_KEY=${E2E_ADMIN_STATIC_API_KEY:-} diff --git a/tests/setup/compose.yaml b/tests/setup/compose.yaml index 53e193c6a2..88c94d3bf1 100644 --- a/tests/setup/compose.yaml +++ b/tests/setup/compose.yaml @@ -1,12 +1,13 @@ services: arcane: image: arcane:playwright-tests - user: root ports: - '3000:3552' environment: - ENVIRONMENT=testing - APP_ENV=test + - PUID=65532 + - PGID=65532 - ENCRYPTION_KEY=3JDIgolks2tJ9ymm1AdqzlYMWu0DUWyt - JWT_SECRET=your-super-secret-jwt-key-change-this-in-production - ADMIN_STATIC_API_KEY=${E2E_ADMIN_STATIC_API_KEY:-} diff --git a/tests/setup/global-setup.ts b/tests/setup/global-setup.ts index 6cad033e1b..a21b8ec213 100644 --- a/tests/setup/global-setup.ts +++ b/tests/setup/global-setup.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -6,6 +6,36 @@ import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +function ensureProjectsDirIsContainerWritable(projectsDir: string) { + fs.mkdirSync(projectsDir, { recursive: true }); + + try { + fs.accessSync(projectsDir, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); + fs.chmodSync(projectsDir, 0o777); + return; + } catch (error) { + console.warn( + 'Projects directory is not writable by the test runner, fixing permissions:', + error + ); + } + + execFileSync( + 'docker', + [ + 'run', + '--rm', + '-v', + `${projectsDir}:/projects`, + 'alpine', + 'sh', + '-c', + 'chmod -R 777 /projects' + ], + { stdio: 'inherit' } + ); +} + async function globalSetup() { console.log('\nStarting global setup...'); @@ -13,6 +43,12 @@ async function globalSetup() { ? path.resolve(__dirname, '..', process.env.COMPOSE_FILE) : path.resolve(__dirname, 'compose.yaml'); + // This directory is bind-mounted into Arcane at /app/data/projects. Create it + // before `docker compose up` so Docker does not create it as root, and make it + // writable for the hardened non-root runtime user (65532). + const projectsDir = path.resolve(__dirname, 'projects'); + ensureProjectsDirIsContainerWritable(projectsDir); + try { console.log('Building and starting Docker containers...'); execSync(`docker compose -f ${composeFile} up -d --build`, { stdio: 'inherit' }); @@ -46,12 +82,6 @@ async function globalSetup() { throw new Error(`Server at ${baseURL} did not become ready in time.`); } - // 3. Ensure the projects directory exists - const projectsDir = path.resolve(__dirname, 'projects'); - if (!fs.existsSync(projectsDir)) { - fs.mkdirSync(projectsDir, { recursive: true }); - } - console.log('Global setup complete.\n'); } diff --git a/tests/setup/project.data.ts b/tests/setup/project.data.ts index 7a91e4860d..c5ec82aa7b 100644 --- a/tests/setup/project.data.ts +++ b/tests/setup/project.data.ts @@ -6,24 +6,19 @@ export const TEST_COMPOSE_YAML = `configs: Used for testing purposes. services: - redis: - image: redis:latest + nginx: + image: ghcr.io/linuxserver/nginx:latest container_name: \${CONTAINER_NAME} configs: - source: some_content - target: /etc/some_content.txt - command: /bin/sh -c 'cat /etc/some_content.txt && redis-server' - ports: - - "8081:81" - - "6379:6379" - - "6378:6378" + target: /config/test-content.txt volumes: - - redis_data:/data + - nginx_data:/config volumes: - redis_data: + nginx_data: driver: local `; -export const TEST_ENV_FILE = `CONTAINER_NAME=test-redis-container +export const TEST_ENV_FILE = `CONTAINER_NAME=test-nginx-container `; diff --git a/tests/spec/docker-runtime-identity.spec.ts b/tests/spec/docker-runtime-identity.spec.ts index 7de110915a..8214655008 100644 --- a/tests/spec/docker-runtime-identity.spec.ts +++ b/tests/spec/docker-runtime-identity.spec.ts @@ -31,6 +31,15 @@ function dockerExecAsUser(container: string, user: string, command: string) { return docker(['exec', '-u', user, container, 'sh', '-lc', command]); } +type ProcessStatus = { + gid: string; + groups: string; + name: string; + pid: string; + ppid: string; + uid: string; +}; + function dockerStatus(container: string) { return docker(['inspect', '-f', '{{.State.Status}}', container]); } @@ -132,28 +141,44 @@ async function waitForFile(container: string, filePath: string) { .toBe('present'); } +function parseStatusBlock(status: string): ProcessStatus { + const fields = new Map(); + + for (const line of status.split('\n')) { + const [key, ...valueParts] = line.split(':'); + if (!key || valueParts.length === 0) continue; + fields.set(key, valueParts.join(':').trim()); + } + + return { + gid: fields.get('Gid') ?? '', + groups: fields.get('Groups') ?? '', + name: fields.get('Name') ?? '', + pid: fields.get('Pid') ?? '', + ppid: fields.get('PPid') ?? '', + uid: fields.get('Uid') ?? '' + }; +} + function pidOneStatus(container: string) { - return dockerExec(container, "grep -E '^(Uid|Gid|Groups):' /proc/1/status"); + return parseStatusBlock(dockerExec(container, 'cat /proc/1/status')); } function arcaneProcessStatuses(container: string) { - return dockerExec( + const output = dockerExec( container, [ 'for f in /proc/[0-9]*/status; do', - ' name=$(awk \'/^Name:/ {print $2}\' "$f");', - ' [ "$name" = "arcane" ] || continue;', - ' pid=$(awk \'/^Pid:/ {print $2}\' "$f");', - ' ppid=$(awk \'/^PPid:/ {print $2}\' "$f");', - ' uid=$(awk \'/^Uid:/ {print $2}\' "$f");', - ' gid=$(awk \'/^Gid:/ {print $2}\' "$f");', - ' groups=$(awk \'/^Groups:/ {for (i = 2; i <= NF; i++) printf("%s%s", $i, (i < NF ? "," : ""));}\' "$f");', - ' echo "$pid:$ppid:$uid:$gid:$groups";', + ' printf "%s\\n" "---ARCANE-PROCESS-STATUS---";', + ' cat "$f";', 'done' ].join(' ') - ) - .split('\n') - .filter(Boolean); + ); + + return output + .split('---ARCANE-PROCESS-STATUS---') + .map((block) => parseStatusBlock(block)) + .filter((status) => status.name === 'arcane'); } function defaultRunArgs(name: string, dataDir: string) { @@ -178,7 +203,7 @@ function defaultRunArgs(name: string, dataDir: string) { test.describe.serial('Docker runtime identity', () => { test.setTimeout(240_000); - test('keeps default root runtime behavior when PUID and PGID are unset', async () => { + test('uses the default non-root runtime when PUID and PGID are unset', async () => { const containerName = uniqueName('arcane-default'); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcane-default-')); @@ -194,8 +219,19 @@ test.describe.serial('Docker runtime identity', () => { await waitForFile(containerName, '/app/data/arcane.db'); const status = pidOneStatus(containerName); - expect(status).toContain('Uid:\t0\t0\t0\t0'); - expect(status).toContain('Gid:\t0\t0\t0\t0'); + expect(status.uid).toBe('0\t0\t0\t0'); + expect(status.gid).toBe('0\t0\t0\t0'); + + const processStatuses = arcaneProcessStatuses(containerName); + expect( + processStatuses.some( + (status) => + status.pid !== '1' && + status.ppid === '1' && + status.uid.startsWith('65532\t') && + status.gid.startsWith('65532\t') + ) + ).toBe(true); } finally { cleanupContainer(containerName); cleanupDir(dataDir); @@ -242,8 +278,20 @@ test.describe.serial('Docker runtime identity', () => { expect(projectsStat).toBe(baselineProjectsStat); const processStatuses = arcaneProcessStatuses(containerName); - expect(processStatuses.some((status) => status.startsWith('1:0:0:0:'))).toBe(true); - expect(processStatuses.some((status) => /^(?!1:)\d+:1:1001:1001:/.test(status))).toBe(true); + expect( + processStatuses.some( + (status) => status.pid === '1' && status.ppid === '0' && status.uid.startsWith('0\t') + ) + ).toBe(true); + expect( + processStatuses.some( + (status) => + status.pid !== '1' && + status.ppid === '1' && + status.uid.startsWith('1001\t') && + status.gid.startsWith('1001\t') + ) + ).toBe(true); const dockerConfigStat = dockerExecAsUser( containerName, @@ -259,6 +307,66 @@ test.describe.serial('Docker runtime identity', () => { } }); + test('default non-root runtime prepares mounted writable roots', async () => { + const containerName = uniqueName('arcane-default-nonroot'); + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcane-default-nonroot-data-')); + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcane-default-nonroot-projects-')); + const buildsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcane-default-nonroot-builds-')); + + try { + dockerRunContainer([ + ...defaultRunArgs(containerName, dataDir), + '-v', + '/var/run/docker.sock:/var/run/docker.sock', + '-v', + `${projectsDir}:/app/data/projects`, + '-v', + `${buildsDir}:/builds`, + IMAGE + ]); + + await waitForHealth(containerName); + await waitForFile(containerName, '/app/data/arcane.db'); + + const processStatuses = arcaneProcessStatuses(containerName); + expect( + processStatuses.some( + (status) => + status.pid !== '1' && + status.ppid === '1' && + status.uid.startsWith('65532\t') && + status.gid.startsWith('65532\t') + ) + ).toBe(true); + + const dataStat = dockerExecAsUser( + containerName, + '65532:65532', + "stat -c '%u:%g' /app/data/arcane.db" + ); + expect(dataStat).toBe('65532:65532'); + + const projectWrite = dockerExecAsUser( + containerName, + '65532:65532', + "touch /app/data/projects/runtime-write && stat -c '%u:%g' /app/data/projects/runtime-write" + ); + expect(projectWrite).toBe('65532:65532'); + + const buildsWrite = dockerExecAsUser( + containerName, + '65532:65532', + "touch /builds/runtime-write && stat -c '%u:%g' /builds/runtime-write" + ); + expect(buildsWrite).toBe('65532:65532'); + } finally { + cleanupContainer(containerName); + cleanupDir(dataDir); + cleanupDir(projectsDir); + cleanupDir(buildsDir); + } + }); + test('supports tcp docker host mode without a mounted Unix socket', async () => { const networkName = uniqueName('arcane-proxy-net'); const proxyName = uniqueName('arcane-proxy'); @@ -323,8 +431,20 @@ test.describe.serial('Docker runtime identity', () => { expect(dbStat).toBe('1001:1001'); const processStatuses = arcaneProcessStatuses(containerName); - expect(processStatuses.some((status) => status.startsWith('1:0:0:0:'))).toBe(true); - expect(processStatuses.some((status) => /^(?!1:)\d+:1:1001:1001:/.test(status))).toBe(true); + expect( + processStatuses.some( + (status) => status.pid === '1' && status.ppid === '0' && status.uid.startsWith('0\t') + ) + ).toBe(true); + expect( + processStatuses.some( + (status) => + status.pid !== '1' && + status.ppid === '1' && + status.uid.startsWith('1001\t') && + status.gid.startsWith('1001\t') + ) + ).toBe(true); } finally { cleanupContainer(containerName); cleanupContainer(proxyName); diff --git a/tests/spec/project.spec.ts b/tests/spec/project.spec.ts index 5ec567c639..c206664e7d 100644 --- a/tests/spec/project.spec.ts +++ b/tests/spec/project.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Locator, type Page } from '@playwright/test'; +import { test, expect, type Locator, type Page, type Response } from '@playwright/test'; import { fetchProjectCountsWithRetry, fetchProjectsWithRetry } from '../utils/fetch.util'; import { Project, ProjectStatusCounts } from 'types/project.type'; import { TEST_COMPOSE_YAML, TEST_ENV_FILE } from '../setup/project.data'; @@ -25,7 +25,7 @@ async function setCodeMirrorValue(page: Page, editor: Locator, text: string) { await expect(content).toBeVisible(); await content.click({ position: { x: 10, y: 10 } }); await content.press('ControlOrMeta+A'); - await page.keyboard.type(text, { delay: 0 }); + await page.keyboard.insertText(text); } async function getCodeMirrorValue(editor: Locator) { @@ -122,8 +122,20 @@ function getProjectIdFromPageUrl(url: string): string { return url.split('/projects/')[1]?.split(/[?#]/)[0] ?? ''; } +async function expectProjectCreateResponse(responsePromise: Promise) { + const response = await responsePromise; + if (response.ok()) { + return response; + } + + const body = await response.text().catch(() => ''); + throw new Error( + `Create project request failed with ${response.status()} ${response.statusText()}: ${body}` + ); +} + async function createProjectViaUI(page: Page, projectName: string) { - const containerName = `test-redis-container-${Date.now()}`; + const containerName = `test-nginx-container-${Date.now()}`; const envFile = TEST_ENV_FILE.replace(/CONTAINER_NAME=.*/m, `CONTAINER_NAME=${containerName}`); await page.goto(ROUTES.newProject); @@ -141,12 +153,23 @@ async function createProjectViaUI(page: Page, projectName: string) { await setCodeMirrorValue(page, composeEditor, TEST_COMPOSE_YAML); await setCodeMirrorValue(page, envEditor, envFile); + await expect + .poll(async () => (await getCodeMirrorValue(composeEditor)).trimEnd(), { + message: 'Expected compose editor to contain the exact test compose fixture before creation' + }) + .toBe(TEST_COMPOSE_YAML.trimEnd()); const createButton = page .getByRole('button', { name: 'Create Project' }) .locator('[data-slot="arcane-button"]'); await expect(createButton).toBeEnabled(); + const createResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + /\/api\/environments\/[^/]+\/projects$/.test(getPathname(response.url())) + ); await createButton.click(); + await expectProjectCreateResponse(createResponsePromise); await page.waitForURL(/\/projects\/(?!new$).+/, { timeout: 10000 }); await expect(page.getByRole('button', { name: projectName })).toBeVisible(); @@ -544,7 +567,7 @@ test.describe('New Compose Project Page', () => { test('should create a new project successfully', async ({ page }) => { const projectName = `test-project-${Date.now()}`; - const containerName = `test-redis-container-${Date.now()}`; + const containerName = `test-nginx-container-${Date.now()}`; const envFile = TEST_ENV_FILE.replace(/CONTAINER_NAME=.*/m, `CONTAINER_NAME=${containerName}`); let createdProjectId: string | null = null; let projectPullRequestCount = 0; @@ -556,12 +579,17 @@ test.describe('New Compose Project Page', () => { const composeEditor = page.locator('.cm-editor:visible').first(); await expect(composeEditor).toBeVisible(); await setCodeMirrorValue(page, composeEditor, TEST_COMPOSE_YAML); - await expect(composeEditor).toContainText(/redis/i); + await expect(composeEditor).toContainText(/nginx/i); + await expect + .poll(async () => (await getCodeMirrorValue(composeEditor)).trimEnd(), { + message: 'Expected compose editor to contain the exact test compose fixture before creation' + }) + .toBe(TEST_COMPOSE_YAML.trimEnd()); const envEditor = page.locator('.cm-editor:visible').nth(1); await expect(envEditor).toBeVisible(); await setCodeMirrorValue(page, envEditor, envFile); - await expect(envEditor).toContainText(/redis/i); + await expect(envEditor).toContainText(/nginx/i); await page.route('/api/environments/*/projects', async (route) => { if (route.request().method() === 'POST') { @@ -570,7 +598,7 @@ test.describe('New Compose Project Page', () => { try { const parsed = JSON.parse(responseBody); - createdProjectId = parsed.id; + createdProjectId = parsed.data?.id ?? parsed.id; } catch { // Keep existing createdProjectId value if parsing fails } @@ -588,7 +616,13 @@ test.describe('New Compose Project Page', () => { const createButton = page .getByRole('button', { name: 'Create Project' }) .locator('[data-slot="arcane-button"]'); + const createResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + /\/api\/environments\/[^/]+\/projects$/.test(getPathname(response.url())) + ); await createButton.click(); + await expectProjectCreateResponse(createResponsePromise); await page.waitForURL(/\/projects\/.+/, { timeout: 10000 }); @@ -604,7 +638,7 @@ test.describe('New Compose Project Page', () => { await page.waitForLoadState('networkidle'); const serviceTable = page.getByRole('table'); - const serviceNameWhenStopped = serviceTable.getByText('redis', { + const serviceNameWhenStopped = serviceTable.getByText('nginx', { exact: true }); const emptyServicesState = page.getByText(/No services found for this project/i); @@ -630,7 +664,7 @@ test.describe('New Compose Project Page', () => { await page.waitForLoadState('networkidle'); expect(projectPullRequestCount).toBe(0); - await expect(page.getByText('Running', { exact: true })).toBeVisible({ + await expect(page.getByText('Running', { exact: true }).first()).toBeVisible({ timeout: 20000 }); await expect(page.getByRole('button', { name: 'Down', exact: true })).toBeVisible(); @@ -784,50 +818,19 @@ test.describe('New Compose Project Page', () => { test('should destroy the project and remove files from disk', async ({ page }) => { const projectName = `test-destroy-${Date.now()}`; + let projectId = ''; - // 1. Create the project first - await page.getByRole('button', { name: 'My New Project' }).click(); - await page.getByRole('textbox', { name: 'My New Project' }).fill(projectName); - await page.getByRole('textbox', { name: 'My New Project' }).press('Enter'); - - const composeEditor = page.locator('.cm-editor:visible').first(); - await expect(composeEditor).toBeVisible(); - await setCodeMirrorValue(page, composeEditor, TEST_COMPOSE_YAML); - - const createButton = page - .locator('button[data-slot="arcane-button"]') - .filter({ hasText: 'Create Project' }); - await createButton.click(); - - await page.waitForURL(/\/projects\/.+/, { timeout: 10000 }); - await expect(page.getByRole('button', { name: projectName })).toBeVisible(); - - // 2. Destroy the project - const destroyButton = page.getByRole('button', { - name: 'Destroy', - exact: true - }); - await expect(destroyButton).toBeVisible(); - await destroyButton.click(); - - // 3. Handle the confirmation dialog - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - // Check "Remove project files" - const removeFilesCheckbox = dialog.getByLabel(/Remove project files/i); - await removeFilesCheckbox.check(); - - // Click "Destroy" in the dialog - const confirmDestroyButton = dialog.getByRole('button', { - name: 'Destroy', - exact: true - }); - await confirmDestroyButton.click(); + try { + projectId = await createProjectViaUI(page, projectName); + await destroyCurrentProjectViaUI(page); + projectId = ''; - // 4. Verify redirection and project removal - await page.waitForURL(ROUTES.page, { timeout: 10000 }); - await expect(page.getByRole('link', { name: projectName })).not.toBeVisible(); + await expect(page.getByRole('link', { name: projectName })).not.toBeVisible(); + } finally { + if (projectId) { + await destroyProjectByIdViaAPI(page, projectId); + } + } }); }); From de7d6b0fe289d8ebece862846b571997cdcae4db Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 24 May 2026 21:45:52 -0500 Subject: [PATCH 03/40] feat: full rbac permissions (#2724) --- .depot/workflows/build-v2.yml | 139 +++ Justfile | 56 +- backend/api/api.go | 8 +- backend/api/diagnostics.go | 5 +- backend/api/handlers/apikeys.go | 45 +- backend/api/handlers/auth.go | 15 +- backend/api/handlers/build_workspaces.go | 19 +- backend/api/handlers/container_registries.go | 34 +- backend/api/handlers/containers.go | 39 +- backend/api/handlers/customize.go | 9 +- backend/api/handlers/dashboard.go | 5 + backend/api/handlers/environments.go | 65 +- backend/api/handlers/events.go | 31 +- backend/api/handlers/git_repositories.go | 35 +- backend/api/handlers/git_repositories_test.go | 115 ++- backend/api/handlers/gitops_syncs.go | 30 +- backend/api/handlers/helpers.go | 9 - backend/api/handlers/image_updates.go | 9 + backend/api/handlers/images.go | 31 +- backend/api/handlers/job_schedules.go | 13 +- backend/api/handlers/networks.go | 20 +- backend/api/handlers/notifications.go | 27 +- backend/api/handlers/oidc.go | 218 +++- backend/api/handlers/ports.go | 3 + backend/api/handlers/projects.go | 69 +- backend/api/handlers/remenv_handlers_test.go | 7 +- backend/api/handlers/roles.go | 421 ++++++++ backend/api/handlers/settings.go | 30 +- backend/api/handlers/swarm.go | 219 +--- backend/api/handlers/system.go | 39 +- backend/api/handlers/templates.go | 42 +- backend/api/handlers/templates_test.go | 18 +- backend/api/handlers/updater.go | 13 +- backend/api/handlers/users.go | 39 +- backend/api/handlers/users_test.go | 37 +- backend/api/handlers/volumes.go | 68 +- backend/api/handlers/vulnerabilities.go | 25 +- backend/api/handlers/webhooks.go | 17 +- backend/api/middleware/auth.go | 123 ++- backend/api/middleware/auth_test.go | 7 +- backend/api/middleware/role.go | 42 +- backend/api/middleware/role_test.go | 120 ++- backend/api/ws/handler.go | 13 +- backend/internal/bootstrap/bootstrap.go | 43 + backend/internal/bootstrap/bootstrap_test.go | 22 + .../internal/bootstrap/router_bootstrap.go | 7 +- .../internal/bootstrap/services_bootstrap.go | 8 +- backend/internal/common/errors.go | 116 +++ backend/internal/config/config.go | 8 +- backend/internal/configschema/schema_test.go | 7 +- .../internal/middleware/auth_middleware.go | 125 ++- backend/internal/models/rbac.go | 72 ++ backend/internal/models/settings.go | 16 +- backend/internal/models/settings_test.go | 8 +- backend/internal/models/user.go | 17 +- backend/internal/services/api_key_service.go | 229 ++++- .../internal/services/api_key_service_test.go | 95 +- backend/internal/services/auth_service.go | 207 ++-- .../internal/services/auth_service_test.go | 32 +- .../services/environment_service_test.go | 1 - .../internal/services/playwright_service.go | 15 +- backend/internal/services/role_service.go | 932 ++++++++++++++++++ .../internal/services/role_service_test.go | 104 ++ .../internal/services/session_service_test.go | 2 - backend/internal/services/settings_service.go | 3 +- backend/internal/services/user_service.go | 285 +++--- .../internal/services/user_service_test.go | 109 +- backend/pkg/authz/catalog.go | 204 ++++ backend/pkg/authz/permission_set.go | 128 +++ backend/pkg/authz/permission_set_test.go | 157 +++ backend/pkg/authz/permissions.go | 362 +++++++ .../edge/proto/tunnel/v1/tunnel_grpc.pb.go | 2 +- backend/pkg/libarcane/startup/startup.go | 7 + backend/pkg/libarcane/startup/startup_test.go | 6 +- backend/pkg/utils/auth.go | 7 - backend/pkg/utils/cache/ttl.go | 7 + backend/pkg/utils/jwtclaims/jwt.go | 33 - .../migrations/postgres/054_add_rbac.down.sql | 17 + .../migrations/postgres/054_add_rbac.up.sql | 53 + .../migrations/sqlite/054_add_rbac.down.sql | 17 + .../migrations/sqlite/054_add_rbac.up.sql | 60 ++ cli/internal/types/endpoints.go | 44 +- cli/pkg/admin/apikeys/cmd.go | 164 +-- cli/pkg/admin/cmd.go | 4 + cli/pkg/admin/oidcmappings/cmd.go | 257 +++++ cli/pkg/admin/roles/cmd.go | 479 +++++++++ cli/pkg/admin/users/cmd.go | 85 +- docs/rbac.md | 180 ++++ frontend/messages/en.json | 96 +- .../src/lib/components/action-buttons.svelte | 323 +++--- .../environment-switcher-dialog.svelte | 13 +- .../dialogs/rbac-migration-banner.svelte | 71 ++ .../components/file-browser/FileList.svelte | 13 +- .../file-browser/GenericFileBrowser.svelte | 71 +- .../forms/role-assignments-editor.svelte | 172 ++++ .../src/lib/components/if-permitted.svelte | 48 + .../mobile-nav/mobile-nav-sheet.svelte | 118 +-- .../mobile-nav/mobile-user-card.svelte | 11 +- .../src/lib/components/quick-actions.svelte | 233 +++-- .../role-editor/permission-picker.svelte | 155 +++ .../components/role-editor/role-editor.svelte | 132 +++ .../sheets/api-key-form-sheet.svelte | 68 +- .../sheets/oidc-mapping-form-sheet.svelte | 151 +++ .../components/sheets/user-form-sheet.svelte | 72 +- .../sidebar/sidebar-updatebanner.svelte | 12 +- .../src/lib/components/sidebar/sidebar.svelte | 33 +- frontend/src/lib/config/navigation-config.ts | 331 ++++++- .../src/lib/services/oidc-mapping-service.ts | 22 + frontend/src/lib/services/role-service.ts | 51 + frontend/src/lib/stores/user-store.ts | 49 +- frontend/src/lib/types/api-key.type.ts | 6 + frontend/src/lib/types/role.type.ts | 106 ++ frontend/src/lib/types/settings.type.ts | 3 +- frontend/src/lib/types/user.type.ts | 22 +- frontend/src/lib/utils/permissions.util.ts | 63 ++ frontend/src/lib/utils/redirect.util.ts | 117 ++- frontend/src/lib/utils/settings.util.ts | 3 +- frontend/src/routes/(app)/+layout.svelte | 7 +- .../src/routes/(app)/containers/+page.svelte | 8 +- .../containers/[containerId]/+page.svelte | 8 +- .../(app)/containers/container-table.svelte | 15 +- .../customize/git-repositories/+page.svelte | 27 +- .../git-repositories/repository-table.svelte | 46 +- .../(app)/customize/registries/+page.svelte | 40 +- .../registries/registry-table.svelte | 62 +- .../(app)/customize/templates/+page.svelte | 42 +- .../customize/templates/template-table.svelte | 23 +- .../(app)/customize/variables/+page.svelte | 85 +- .../src/routes/(app)/dashboard/+page.svelte | 1 - .../dashboard-all-environments-view.svelte | 23 +- ...ashboard-environment-upgrade-action.svelte | 12 +- .../routes/(app)/environments/+page.svelte | 10 +- .../environments/environment-table.svelte | 11 +- frontend/src/routes/(app)/events/+page.svelte | 5 +- .../routes/(app)/events/event-table.svelte | 21 +- frontend/src/routes/(app)/images/+page.svelte | 66 +- .../(app)/images/[imageId]/+page.svelte | 46 +- .../routes/(app)/images/builds/+page.svelte | 19 +- .../builds/build-workspace-browser.svelte | 48 +- .../builds/components/build-controls.svelte | 23 +- .../routes/(app)/images/image-table.svelte | 77 +- .../(app)/images/vulnerabilities/+page.svelte | 39 +- .../ignored-vulnerabilities-table.svelte | 19 +- .../security-vulnerability-table.svelte | 25 +- .../src/routes/(app)/no-access/+page.svelte | 14 + .../src/routes/(app)/projects/+page.svelte | 46 +- .../(app)/projects/[projectId]/+page.svelte | 101 +- .../components/ProjectContainersTable.svelte | 126 +-- .../routes/(app)/projects/new/+page.svelte | 47 +- .../(app)/projects/projects-table.svelte | 203 ++-- .../(app)/settings/api-keys/+page.svelte | 15 +- .../routes/(app)/settings/api-keys/+page.ts | 19 +- .../settings/api-keys/api-key-table.svelte | 41 +- .../settings/authentication/+page.svelte | 149 ++- .../(app)/settings/authentication/+page.ts | 27 + .../authentication/oidc-mapping-table.svelte | 253 +++++ .../routes/(app)/settings/roles/+page.svelte | 48 + .../src/routes/(app)/settings/roles/+page.ts | 35 + .../(app)/settings/roles/[id]/+page.svelte | 56 ++ .../routes/(app)/settings/roles/[id]/+page.ts | 37 + .../(app)/settings/roles/new/+page.svelte | 36 + .../routes/(app)/settings/roles/new/+page.ts | 25 + .../(app)/settings/roles/roles-table.svelte | 308 ++++++ .../routes/(app)/settings/users/+page.svelte | 47 +- .../src/routes/(app)/settings/users/+page.ts | 26 +- .../(app)/settings/users/user-table.svelte | 123 ++- .../settings/webhooks/webhook-table.svelte | 19 +- .../routes/(app)/swarm/cluster/+page.svelte | 22 +- .../routes/(app)/swarm/configs/+page.svelte | 27 +- .../(app)/swarm/nodes/nodes-table.svelte | 24 +- .../routes/(app)/swarm/secrets/+page.svelte | 27 +- .../routes/(app)/swarm/services/+page.svelte | 29 +- .../swarm/services/[serviceId]/+page.svelte | 7 +- .../swarm/services/services-table.svelte | 11 +- .../routes/(app)/swarm/stacks/+page.svelte | 29 +- .../(app)/swarm/stacks/stacks-table.svelte | 11 +- .../updates/container-updates-table.svelte | 10 +- .../src/routes/(app)/volumes/+page.svelte | 31 +- .../(app)/volumes/[volumeName]/+page.svelte | 29 +- .../components/volume-backup-table.svelte | 72 +- .../routes/(app)/volumes/volume-table.svelte | 27 +- .../routes/(auth)/oidc/callback/+page.svelte | 32 +- tests/spec/api-keys.spec.ts | 9 + tests/spec/cli.spec.ts | 7 +- types/apikey/apikey.go | 43 +- types/role/role.go | 125 +++ types/settings/settings.go | 10 +- types/user/user.go | 56 +- 188 files changed, 10226 insertions(+), 2651 deletions(-) create mode 100644 .depot/workflows/build-v2.yml create mode 100644 backend/api/handlers/roles.go create mode 100644 backend/internal/models/rbac.go create mode 100644 backend/internal/services/role_service.go create mode 100644 backend/internal/services/role_service_test.go create mode 100644 backend/pkg/authz/catalog.go create mode 100644 backend/pkg/authz/permission_set.go create mode 100644 backend/pkg/authz/permission_set_test.go create mode 100644 backend/pkg/authz/permissions.go create mode 100644 backend/resources/migrations/postgres/054_add_rbac.down.sql create mode 100644 backend/resources/migrations/postgres/054_add_rbac.up.sql create mode 100644 backend/resources/migrations/sqlite/054_add_rbac.down.sql create mode 100644 backend/resources/migrations/sqlite/054_add_rbac.up.sql create mode 100644 cli/pkg/admin/oidcmappings/cmd.go create mode 100644 cli/pkg/admin/roles/cmd.go create mode 100644 docs/rbac.md create mode 100644 frontend/src/lib/components/dialogs/rbac-migration-banner.svelte create mode 100644 frontend/src/lib/components/forms/role-assignments-editor.svelte create mode 100644 frontend/src/lib/components/if-permitted.svelte create mode 100644 frontend/src/lib/components/role-editor/permission-picker.svelte create mode 100644 frontend/src/lib/components/role-editor/role-editor.svelte create mode 100644 frontend/src/lib/components/sheets/oidc-mapping-form-sheet.svelte create mode 100644 frontend/src/lib/services/oidc-mapping-service.ts create mode 100644 frontend/src/lib/services/role-service.ts create mode 100644 frontend/src/lib/types/role.type.ts create mode 100644 frontend/src/lib/utils/permissions.util.ts create mode 100644 frontend/src/routes/(app)/no-access/+page.svelte create mode 100644 frontend/src/routes/(app)/settings/authentication/+page.ts create mode 100644 frontend/src/routes/(app)/settings/authentication/oidc-mapping-table.svelte create mode 100644 frontend/src/routes/(app)/settings/roles/+page.svelte create mode 100644 frontend/src/routes/(app)/settings/roles/+page.ts create mode 100644 frontend/src/routes/(app)/settings/roles/[id]/+page.svelte create mode 100644 frontend/src/routes/(app)/settings/roles/[id]/+page.ts create mode 100644 frontend/src/routes/(app)/settings/roles/new/+page.svelte create mode 100644 frontend/src/routes/(app)/settings/roles/new/+page.ts create mode 100644 frontend/src/routes/(app)/settings/roles/roles-table.svelte create mode 100644 types/role/role.go diff --git a/.depot/workflows/build-v2.yml b/.depot/workflows/build-v2.yml new file mode 100644 index 0000000000..789f41b1b7 --- /dev/null +++ b/.depot/workflows/build-v2.yml @@ -0,0 +1,139 @@ +name: Build V2 Beta Images + +on: + push: + branches: + - breaking/v2.0.0 + workflow_dispatch: + +concurrency: + group: build-v2-beta-images + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + build-v2-beta-images: + runs-on: depot-ubuntu-24.04-32 + + env: + MANAGER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/manager + AGENT_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/agent + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install cosign + uses: sigstore/cosign-installer@v4.1.2 + + - name: Set up Depot CLI + uses: depot/setup-action@v1 + + - name: Setup depot buildx driver + id: setup-buildx-driver + run: | + depot configure-docker + + # - name: Snapshot Setup Environment + # uses: depot/snapshot-action@v1 + # with: + # image: g7r5wqb57k.registry.depot.dev/arcane-env:latest + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ vars.GHCR_USERNAME }} + password: ${{ secrets.ARCANE_BOT_TOKEN }} + + - name: Manager image metadata + id: manager-meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.MANAGER_IMAGE_NAME }} + tags: | + type=raw,value=v2-beta + labels: | + org.opencontainers.image.authors=OFKM Technologies + org.opencontainers.image.url=https://github.com/getarcaneapp/arcane + org.opencontainers.image.documentation=https://github.com/getarcaneapp/arcane/blob/main/README.md + org.opencontainers.image.source=https://github.com/getarcaneapp/arcane + org.opencontainers.image.version=v2-beta + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=BSD-3-Clause + org.opencontainers.image.ref.name=arcane + org.opencontainers.image.title=Arcane + org.opencontainers.image.description=Modern Docker Management, Made for Everyone + com.getarcaneapp.arcane=true + + - name: Build and push manager image + id: manager-build + uses: depot/build-push-action@v1 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: ${{ steps.manager-meta.outputs.tags }} + labels: ${{ steps.manager-meta.outputs.labels }} + build-args: | + VERSION=v2-beta + REVISION=${{ github.sha }} + sbom: false + provenance: true + + - name: Sign manager image + run: cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${MANAGER_IMAGE_NAME}@${{ steps.manager-build.outputs.digest }}" + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + + - name: Agent image metadata + id: agent-meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.AGENT_IMAGE_NAME }} + tags: | + type=raw,value=v2-beta + labels: | + org.opencontainers.image.authors=OFKM Technologies + org.opencontainers.image.url=https://github.com/getarcaneapp/arcane + org.opencontainers.image.documentation=https://github.com/getarcaneapp/arcane/blob/main/README.md + org.opencontainers.image.source=https://github.com/getarcaneapp/arcane + org.opencontainers.image.version=v2-beta + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=BSD-3-Clause + org.opencontainers.image.ref.name=arcane-agent + org.opencontainers.image.title=Arcane Agent + org.opencontainers.image.description=Arcane Agent + com.getarcaneapp.arcane=true + com.getarcaneapp.arcane.agent=true + + - name: Build and push agent image + id: agent-build + uses: depot/build-push-action@v1 + with: + context: . + file: docker/Dockerfile-agent + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: ${{ steps.agent-meta.outputs.tags }} + labels: ${{ steps.agent-meta.outputs.labels }} + build-args: | + VERSION=v2-beta + REVISION=${{ github.sha }} + sbom: false + provenance: true + + - name: Sign agent image + run: cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${AGENT_IMAGE_NAME}@${{ steps.agent-build.outputs.digest }}" + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} diff --git a/Justfile b/Justfile index 23ea143cde..1baf3e8847 100644 --- a/Justfile +++ b/Justfile @@ -156,10 +156,58 @@ _build-image-manager tag="ghcr.io/getarcaneapp/arcane:development" flag='': _build-image-agent tag="ghcr.io/getarcaneapp/arcane-headless:development" flag='': docker buildx build {{ if flag == "--push" { "--push" } else { "" } }} --platform linux/arm64,linux/amd64,linux/arm/v7 -f 'docker/Dockerfile-agent' --build-arg ENABLED_FEATURES="{{ env('ENABLED_FEATURES', env('BUILD_FEATURES', '')) }}" -t "{{ tag }}" . -# Build targets. Valid: "single frontend", "single backend", "single all", "image manager [tag] [--push]", "image agent [tag] [--push]" +# Build + push both manager and agent multi-arch images for a beta release. +# +# Tag pattern: ghcr.io/getarcaneapp/{manager,agent}:-beta +# Version flag: .0.0-beta (compiled into the binary via -ldflags), +# unless an explicit version is supplied as the second arg. +# +# Examples: +# just build v2 tags :v2-beta, VERSION=v2.0.0-beta +# just build v2 v2.0.0-beta.2 tags :v2-beta, VERSION=v2.0.0-beta.2 [group('build')] -build buildtype type tag="" flag="": - @if [ "{{ buildtype }}" = "single" ]; then just _build-{{ type }}; elif [ "{{ buildtype }}" = "image" ]; then just _build-image-{{ type }} "{{ if tag != "" { tag } else if type == "manager" { "arcane:latest" } else { "arcane-agent:latest" } }}" "{{ flag }}"; fi +_build-release release version="": + #!/usr/bin/env bash + set -euo pipefail + + if [ -z "{{ release }}" ]; then + echo "Release shortcut is required, e.g. 'just build v2'" >&2 + exit 1 + fi + + image_tag="{{ release }}-beta" + version="{{ if version != "" { version } else { release + ".0.0-beta" } }}" + + echo "==> Building manager image ghcr.io/getarcaneapp/manager:${image_tag} (VERSION=${version})" + docker buildx build \ + --tag "ghcr.io/getarcaneapp/manager:${image_tag}" \ + --push \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="${version}" \ + -f docker/Dockerfile . + + echo "==> Building agent image ghcr.io/getarcaneapp/agent:${image_tag} (VERSION=${version})" + docker buildx build \ + --tag "ghcr.io/getarcaneapp/agent:${image_tag}" \ + --push \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="${version}" \ + -f docker/Dockerfile-agent . + + echo "" + echo "✓ Pushed manager + agent images tagged :${image_tag} (VERSION=${version})" + +# Build targets: +# just build single {frontend|backend|all} +# just build image {manager|agent} [tag] [--push] +# just build [version] e.g. just build v2 -> push manager+agent :v2-beta with VERSION=v2.0.0-beta +[group('build')] +build buildtype type="" tag="" flag="": + @if [ "{{ buildtype }}" = "single" ]; then just _build-{{ type }}; \ + elif [ "{{ buildtype }}" = "image" ]; then just _build-image-{{ type }} "{{ if tag != "" { tag } else if type == "manager" { "arcane:latest" } else { "arcane-agent:latest" } }}" "{{ flag }}"; \ + elif echo "{{ buildtype }}" | grep -qE '^v[0-9]'; then just _build-release "{{ buildtype }}" "{{ type }}"; \ + else echo "Unknown build target: {{ buildtype }}. Try: just build single|image|" >&2; exit 1; \ + fi # ----------------------------------------------------------------------------- # Test @@ -434,7 +482,7 @@ gomod action="tidy" target="all": # Generate edge tunnel protobuf/gRPC code. [group('codegen')] proto-backend: - cd {{ edge_proto_dir }} && go run github.com/bufbuild/buf/cmd/buf@v1.65.0 generate + cd {{ edge_proto_dir }} && go run github.com/bufbuild/buf/cmd/buf@latest generate # Generate the docs config schema JSON. [group('docs')] diff --git a/backend/api/api.go b/backend/api/api.go index a236f7c791..1452ef40e0 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -173,6 +173,7 @@ type Services struct { Webhook *services.WebhookService Vulnerability *services.VulnerabilityService Dashboard *services.DashboardService + Role *services.RoleService Config *config.Config } @@ -223,7 +224,7 @@ func SetupAPI(e *echo.Echo, apiGroup *echo.Group, cfg *config.Config, svc *Servi api := humaecho.NewWithGroup(e, apiGroup, humaConfig) // Add authentication middleware - api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, svc.Environment, cfg)) + api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, svc.Role, svc.Environment, cfg)) // Register all Huma handlers registerHandlers(api, svc) @@ -345,6 +346,7 @@ func registerHandlers(api huma.API, svc *Services) { var webhookSvc *services.WebhookService var vulnerabilitySvc *services.VulnerabilityService var dashboardSvc *services.DashboardService + var roleSvc *services.RoleService var cfg *config.Config if svc != nil { @@ -383,18 +385,20 @@ func registerHandlers(api huma.API, svc *Services) { webhookSvc = svc.Webhook vulnerabilitySvc = svc.Vulnerability dashboardSvc = svc.Dashboard + roleSvc = svc.Role cfg = svc.Config } handlers.RegisterHealth(api) handlers.RegisterAuth(api, userSvc, authSvc, oidcSvc) handlers.RegisterApiKeys(api, apiKeySvc) + handlers.RegisterRoles(api, roleSvc) handlers.RegisterAppImages(api, appImagesSvc) handlers.RegisterFonts(api, fontSvc) handlers.RegisterProjects(api, projectSvc) handlers.RegisterUsers(api, userSvc, authSvc) handlers.RegisterVersion(api, versionSvc) handlers.RegisterEvents(api, eventSvc, apiKeySvc) - handlers.RegisterOidc(api, authSvc, oidcSvc, cfg) + handlers.RegisterOidc(api, authSvc, oidcSvc, roleSvc, userSvc, cfg) handlers.RegisterEnvironments(api, environmentSvc, settingsSvc, apiKeySvc, eventSvc, cfg) handlers.RegisterContainerRegistries(api, containerRegistrySvc, environmentSvc) handlers.RegisterTemplates(api, templateSvc, environmentSvc) diff --git a/backend/api/diagnostics.go b/backend/api/diagnostics.go index 383b42d4da..6d9f2acbb2 100644 --- a/backend/api/diagnostics.go +++ b/backend/api/diagnostics.go @@ -7,6 +7,7 @@ import ( "github.com/getarcaneapp/arcane/backend/api/ws" "github.com/getarcaneapp/arcane/backend/internal/middleware" + "github.com/getarcaneapp/arcane/backend/pkg/authz" wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" "github.com/labstack/echo/v4" ) @@ -23,8 +24,8 @@ func RegisterDiagnosticsRoutes(group *echo.Group, authMiddleware *middleware.Aut } func (h *DiagnosticsHandler) WebSocketDiagnostics(c echo.Context) error { - val := c.Get("userIsAdmin") - if admin, ok := val.(bool); !ok || !admin { + ps, _ := c.Get("userPermissions").(*authz.PermissionSet) + if !ps.IsGlobalAdmin() { return c.JSON(http.StatusForbidden, map[string]any{"error": "Admin access required"}) } diff --git a/backend/api/handlers/apikeys.go b/backend/api/handlers/apikeys.go index f0fb858fa1..583336a48a 100644 --- a/backend/api/handlers/apikeys.go +++ b/backend/api/handlers/apikeys.go @@ -9,6 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/apikey" "github.com/getarcaneapp/arcane/types/base" @@ -90,7 +91,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysList), }, h.ListApiKeys) huma.Register(api, huma.Operation{ @@ -104,7 +105,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysCreate), }, h.CreateApiKey) huma.Register(api, huma.Operation{ @@ -118,7 +119,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysRead), }, h.GetApiKey) huma.Register(api, huma.Operation{ @@ -132,7 +133,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysUpdate), }, h.UpdateApiKey) huma.Register(api, huma.Operation{ @@ -146,7 +147,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysDelete), }, h.DeleteApiKey) } @@ -156,11 +157,6 @@ func (h *ApiKeyHandler) ListApiKeys(ctx context.Context, input *ListApiKeysInput return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - params := pagination.QueryParams{ SearchQuery: pagination.SearchQuery{ Search: input.Search, @@ -206,13 +202,11 @@ func (h *ApiKeyHandler) CreateApiKey(ctx context.Context, input *CreateApiKeyInp return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - apiKey, err := h.apiKeyService.CreateApiKey(ctx, user.ID, input.Body) if err != nil { + if errors.Is(err, services.ErrApiKeyPermissionEscalation) { + return nil, huma.Error403Forbidden(err.Error()) + } return nil, huma.Error500InternalServerError((&common.ApiKeyCreationError{Err: err}).Error()) } @@ -230,11 +224,6 @@ func (h *ApiKeyHandler) GetApiKey(ctx context.Context, input *GetApiKeyInput) (* return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - apiKey, err := h.apiKeyService.GetApiKey(ctx, input.ID) if err != nil { return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) @@ -254,12 +243,12 @@ func (h *ApiKeyHandler) UpdateApiKey(ctx context.Context, input *UpdateApiKeyInp return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - apiKey, err := h.apiKeyService.UpdateApiKey(ctx, input.ID, input.Body) + apiKey, err := h.apiKeyService.UpdateApiKey(ctx, user.ID, input.ID, input.Body) if err != nil { if errors.Is(err, services.ErrApiKeyNotFound) { return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) @@ -267,6 +256,9 @@ func (h *ApiKeyHandler) UpdateApiKey(ctx context.Context, input *UpdateApiKeyInp if errors.Is(err, services.ErrApiKeyProtected) { return nil, huma.Error403Forbidden("static API keys cannot be updated") } + if errors.Is(err, services.ErrApiKeyPermissionEscalation) { + return nil, huma.Error403Forbidden(err.Error()) + } return nil, huma.Error500InternalServerError((&common.ApiKeyUpdateError{Err: err}).Error()) } @@ -284,11 +276,6 @@ func (h *ApiKeyHandler) DeleteApiKey(ctx context.Context, input *DeleteApiKeyInp return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.apiKeyService.DeleteApiKey(ctx, input.ID); err != nil { if errors.Is(err, services.ErrApiKeyNotFound) { return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) diff --git a/backend/api/handlers/auth.go b/backend/api/handlers/auth.go index 38907d3a16..b5abec8511 100644 --- a/backend/api/handlers/auth.go +++ b/backend/api/handlers/auth.go @@ -12,7 +12,6 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/auth" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/user" @@ -155,9 +154,9 @@ func (h *AuthHandler) Login(ctx context.Context, input *LoginInput) (*LoginOutpu } } - var userResp user.User - if mapErr := mapper.MapStruct(userModel, &userResp); mapErr != nil { - return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: mapErr}).Error()) + userResp, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) } maxAge := max(int(time.Until(tokenPair.ExpiresAt).Seconds()), 0) @@ -202,6 +201,8 @@ func (h *AuthHandler) Logout(ctx context.Context, input *struct{}) (*LogoutOutpu } // GetCurrentUser returns the currently authenticated user's information. +// Uses ToUserResponseDto (not the generic struct mapper) so the RBAC fields +// (RoleAssignments, PermissionsByEnv) are resolved via RoleService. func (h *AuthHandler) GetCurrentUser(ctx context.Context, input *struct{}) (*GetCurrentUserOutput, error) { if h.userService == nil { return nil, huma.Error500InternalServerError("service not available") @@ -217,9 +218,9 @@ func (h *AuthHandler) GetCurrentUser(ctx context.Context, input *struct{}) (*Get return nil, huma.Error500InternalServerError((&common.UserRetrievalError{Err: err}).Error()) } - var out user.User - if mapErr := mapper.MapStruct(userModel, &out); mapErr != nil { - return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: mapErr}).Error()) + out, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) } return &GetCurrentUserOutput{ diff --git a/backend/api/handlers/build_workspaces.go b/backend/api/handlers/build_workspaces.go index 2426258111..efaee82c27 100644 --- a/backend/api/handlers/build_workspaces.go +++ b/backend/api/handlers/build_workspaces.go @@ -10,6 +10,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" volumetypes "github.com/getarcaneapp/arcane/types/volume" ) @@ -31,6 +32,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "List files and directories under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.BrowseDirectory) huma.Register(api, huma.Operation{ @@ -41,6 +43,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Read file content under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.GetFileContent) huma.Register(api, huma.Operation{ @@ -51,6 +54,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Download a file from the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.DownloadFile) huma.Register(api, huma.Operation{ @@ -78,7 +82,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.UploadFile) huma.Register(api, huma.Operation{ @@ -89,7 +93,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Create a directory under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.CreateDirectory) huma.Register(api, huma.Operation{ @@ -100,7 +104,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Delete a file or directory under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.DeleteFile) } @@ -198,9 +202,6 @@ func (h *BuildWorkspaceHandler) DownloadFile(ctx context.Context, input *Downloa } func (h *BuildWorkspaceHandler) UploadFile(ctx context.Context, input *UploadBuildFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.service == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -227,9 +228,6 @@ func (h *BuildWorkspaceHandler) UploadFile(ctx context.Context, input *UploadBui } func (h *BuildWorkspaceHandler) CreateDirectory(ctx context.Context, input *CreateBuildDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.service == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -243,9 +241,6 @@ func (h *BuildWorkspaceHandler) CreateDirectory(ctx context.Context, input *Crea } func (h *BuildWorkspaceHandler) DeleteFile(ctx context.Context, input *DeleteBuildFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.service == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/container_registries.go b/backend/api/handlers/container_registries.go index f8ebf01b67..0124363b2f 100644 --- a/backend/api/handlers/container_registries.go +++ b/backend/api/handlers/container_registries.go @@ -10,6 +10,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" @@ -120,6 +121,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesList), }, h.ListRegistries) huma.Register(api, huma.Operation{ @@ -133,7 +135,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesCreate), }, h.CreateRegistry) huma.Register(api, huma.Operation{ @@ -147,7 +149,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesUpdate), }, h.SyncRegistries) huma.Register(api, huma.Operation{ @@ -161,6 +163,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesRead), }, h.GetPullUsage) huma.Register(api, huma.Operation{ @@ -174,6 +177,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesRead), }, h.GetRegistry) huma.Register(api, huma.Operation{ @@ -187,7 +191,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesUpdate), }, h.UpdateRegistry) huma.Register(api, huma.Operation{ @@ -201,7 +205,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesDelete), }, h.DeleteRegistry) huma.Register(api, huma.Operation{ @@ -215,7 +219,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesTest), }, h.TestRegistry) } @@ -276,10 +280,6 @@ func (h *ContainerRegistryHandler) CreateRegistry(ctx context.Context, input *Cr return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - reg, err := h.registryService.CreateRegistry(ctx, input.Body) if err != nil { apiErr := models.ToAPIError(err) @@ -332,10 +332,6 @@ func (h *ContainerRegistryHandler) UpdateRegistry(ctx context.Context, input *Up return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - reg, err := h.registryService.UpdateRegistry(ctx, input.ID, input.Body) if err != nil { apiErr := models.ToAPIError(err) @@ -363,10 +359,6 @@ func (h *ContainerRegistryHandler) DeleteRegistry(ctx context.Context, input *De return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.registryService.DeleteRegistry(ctx, input.ID); err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.RegistryDeletionError{Err: err}).Error()) @@ -390,10 +382,6 @@ func (h *ContainerRegistryHandler) TestRegistry(ctx context.Context, input *Test return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - reg, err := h.registryService.GetRegistryByID(ctx, input.ID) if err != nil { apiErr := models.ToAPIError(err) @@ -445,10 +433,6 @@ func (h *ContainerRegistryHandler) SyncRegistries(ctx context.Context, input *Sy return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.registryService.SyncRegistries(ctx, input.Body.Registries); err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.RegistrySyncError{Err: err}).Error()) diff --git a/backend/api/handlers/containers.go b/backend/api/handlers/containers.go index fd4ddfa775..0f00bcc1b4 100644 --- a/backend/api/handlers/containers.go +++ b/backend/api/handlers/containers.go @@ -11,6 +11,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" @@ -150,6 +151,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Description: "Paginated list of containers", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersList), }, h.ListContainers) huma.Register(api, huma.Operation{ @@ -159,6 +161,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Container status counts", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersList), }, h.GetContainerStatusCounts) huma.Register(api, huma.Operation{ @@ -168,7 +171,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Create container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersCreate), }, h.CreateContainer) huma.Register(api, huma.Operation{ @@ -178,6 +181,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Get container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersRead), }, h.GetContainer) huma.Register(api, huma.Operation{ @@ -187,7 +191,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Start container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStart), }, h.StartContainer) huma.Register(api, huma.Operation{ @@ -197,7 +201,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Stop container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStop), }, h.StopContainer) huma.Register(api, huma.Operation{ @@ -207,7 +211,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Restart container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersRestart), }, h.RestartContainer) huma.Register(api, huma.Operation{ @@ -218,7 +222,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Description: "Pull latest image and recreate container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersRedeploy), }, h.RedeployContainer) huma.Register(api, huma.Operation{ @@ -228,7 +232,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Delete container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersDelete), }, h.DeleteContainer) huma.Register(api, huma.Operation{ @@ -239,7 +243,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Description: "Enable or disable auto-update for a specific container", Tags: []string{"Containers", "Updater"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersAutoUpdate), }, h.SetAutoUpdate) } @@ -536,9 +540,6 @@ func buildNetworkingConfig(body containertypes.Create) *network.NetworkingConfig } func (h *ContainerHandler) CreateContainer(ctx context.Context, input *CreateContainerInput) (*CreateContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -605,9 +606,6 @@ func (h *ContainerHandler) GetContainer(ctx context.Context, input *GetContainer } func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -630,9 +628,6 @@ func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerA } func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -655,9 +650,6 @@ func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerAc } func (h *ContainerHandler) RestartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -680,9 +672,6 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe } func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *ContainerActionInput) (*GetContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -721,9 +710,6 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain } func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteContainerInput) (*DeleteContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -746,9 +732,6 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon } func (h *ContainerHandler) SetAutoUpdate(ctx context.Context, input *SetAutoUpdateInput) (*SetAutoUpdateOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.settingsService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/customize.go b/backend/api/handlers/customize.go index 67ae280e70..0a00fab582 100644 --- a/backend/api/handlers/customize.go +++ b/backend/api/handlers/customize.go @@ -9,6 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/category" "github.com/getarcaneapp/arcane/types/search" ) @@ -51,6 +52,7 @@ func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeS {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermCustomizeManage), }, h.Search) huma.Register(api, huma.Operation{ @@ -64,6 +66,7 @@ func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeS {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermCustomizeManage), }, h.GetCategories) } @@ -79,7 +82,8 @@ func (h *CustomizeHandler) Search(ctx context.Context, input *SearchCustomizeInp results := h.customizeSearchService.Search(input.Body.Query) - if !humamw.IsAdminFromContext(ctx) { + ps, _ := humamw.PermissionsFromContext(ctx) + if !ps.IsGlobalAdmin() { filtered := []category.Category{} for _, cat := range results.Results { if cat.ID != "registries" && cat.ID != "variables" { @@ -103,7 +107,8 @@ func (h *CustomizeHandler) GetCategories(ctx context.Context, input *GetCustomiz categories := h.customizeSearchService.GetCustomizeCategories() - if !humamw.IsAdminFromContext(ctx) { + ps, _ := humamw.PermissionsFromContext(ctx) + if !ps.IsGlobalAdmin() { filtered := []category.Category{} for _, cat := range categories { if cat.ID != "registries" && cat.ID != "variables" { diff --git a/backend/api/handlers/dashboard.go b/backend/api/handlers/dashboard.go index 742240bdab..c46a78afde 100644 --- a/backend/api/handlers/dashboard.go +++ b/backend/api/handlers/dashboard.go @@ -5,7 +5,9 @@ import ( "net/http" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" ) @@ -54,6 +56,7 @@ func RegisterDashboard(api huma.API, dashboardService *services.DashboardService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), }, h.GetDashboard) huma.Register(api, huma.Operation{ @@ -67,6 +70,7 @@ func RegisterDashboard(api huma.API, dashboardService *services.DashboardService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), }, h.GetActionItems) huma.Register(api, huma.Operation{ @@ -80,6 +84,7 @@ func RegisterDashboard(api huma.API, dashboardService *services.DashboardService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), }, h.GetEnvironmentsOverview) } diff --git a/backend/api/handlers/environments.go b/backend/api/handlers/environments.go index dc137827a4..3cc434763c 100644 --- a/backend/api/handlers/environments.go +++ b/backend/api/handlers/environments.go @@ -20,6 +20,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -219,6 +220,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsList), }, h.ListEnvironments) huma.Register(api, huma.Operation{ @@ -232,7 +234,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsCreate), }, h.CreateEnvironment) huma.Register(api, huma.Operation{ @@ -246,6 +248,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.GetEnvironment) huma.Register(api, huma.Operation{ @@ -259,7 +262,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsUpdate), }, h.UpdateEnvironment) huma.Register(api, huma.Operation{ @@ -273,7 +276,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsDelete), }, h.DeleteEnvironment) huma.Register(api, huma.Operation{ @@ -287,7 +290,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.TestConnection) huma.Register(api, huma.Operation{ @@ -301,6 +304,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsSync), }, h.UpdateHeartbeat) huma.Register(api, huma.Operation{ @@ -314,7 +318,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsPair), }, h.PairAgent) huma.Register(api, huma.Operation{ @@ -328,7 +332,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsSync), }, h.SyncEnvironment) huma.Register(api, huma.Operation{ @@ -353,7 +357,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.GetDeploymentSnippets) huma.Register(api, huma.Operation{ @@ -367,7 +371,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.DownloadEnvironmentMTLSBundle) huma.Register(api, huma.Operation{ @@ -381,7 +385,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.DownloadEnvironmentMTLSFile) huma.Register(api, huma.Operation{ @@ -395,6 +399,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.GetEnvironmentVersion) huma.Register(api, huma.Operation{ @@ -408,7 +413,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.DownloadEdgeMTLSCA) } @@ -469,10 +474,6 @@ func (h *EnvironmentHandler) CreateEnvironment(ctx context.Context, input *Creat return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) @@ -616,10 +617,6 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - isLocalEnv := input.ID == localDockerEnvironmentID updates := h.buildUpdateMap(&input.Body, isLocalEnv) @@ -713,10 +710,6 @@ func (h *EnvironmentHandler) DeleteEnvironment(ctx context.Context, input *Delet return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.ID == localDockerEnvironmentID { return nil, huma.Error400BadRequest((&common.LocalEnvironmentDeletionError{}).Error()) } @@ -747,10 +740,6 @@ func (h *EnvironmentHandler) TestConnection(ctx context.Context, input *TestConn return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - var apiUrl *string if input.Body != nil { apiUrl = input.Body.ApiUrl @@ -802,10 +791,6 @@ func (h *EnvironmentHandler) PairAgent(ctx context.Context, input *PairAgentInpu return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.ID != localDockerEnvironmentID { return nil, huma.Error404NotFound("Not found") } @@ -835,10 +820,6 @@ func (h *EnvironmentHandler) SyncEnvironment(ctx context.Context, input *SyncEnv return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - // Sync registries if err := h.environmentService.SyncRegistriesToEnvironment(ctx, input.ID); err != nil { slog.WarnContext(ctx, "Failed to sync registries", "environmentID", input.ID, "error", err.Error()) @@ -995,10 +976,6 @@ func (h *EnvironmentHandler) GetDeploymentSnippets(ctx context.Context, input *G return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - env, err := h.environmentService.GetEnvironmentByID(ctx, input.ID) if err != nil { return nil, huma.Error404NotFound("Environment not found") @@ -1150,10 +1127,6 @@ func (h *EnvironmentHandler) GetEnvironmentVersion(ctx context.Context, input *G // DownloadEdgeMTLSCA downloads the Arcane-managed edge mTLS CA certificate. func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *DownloadEdgeMTLSCAInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - caPath, err := generatedEdgeMTLSCAPathInternal(h.cfg) if err != nil { return nil, huma.Error404NotFound("Arcane-managed edge mTLS CA is not available") @@ -1192,10 +1165,6 @@ func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *Download } func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, input *DownloadEnvironmentMTLSBundleInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - env, files, err := h.loadEnvironmentMTLSFiles(ctx, input.ID) if err != nil { return nil, err @@ -1254,10 +1223,6 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, } func (h *EnvironmentHandler) DownloadEnvironmentMTLSFile(ctx context.Context, input *DownloadEnvironmentMTLSFileInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - env, file, err := h.loadEnvironmentMTLSFile(ctx, input.ID, input.FileName) if err != nil { return nil, err diff --git a/backend/api/handlers/events.go b/backend/api/handlers/events.go index 5d0ff6658a..4d948a090c 100644 --- a/backend/api/handlers/events.go +++ b/backend/api/handlers/events.go @@ -7,6 +7,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/event" ) @@ -96,7 +97,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.ListEvents) huma.Register(api, huma.Operation{ @@ -110,7 +111,10 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + // TODO: introduce a dedicated PermEventsCreate (and PermEventsDelete) + // permission. Today the events taxonomy only exposes PermEventsRead, + // so admin-level write actions reuse the read permission. + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.CreateEvent) huma.Register(api, huma.Operation{ @@ -124,7 +128,10 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + // TODO: introduce a dedicated PermEventsDelete permission. Today the + // events taxonomy only exposes PermEventsRead, so admin-level write + // actions reuse the read permission. + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.DeleteEvent) huma.Register(api, huma.Operation{ @@ -138,7 +145,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.GetEventsByEnvironment) } @@ -152,10 +159,6 @@ func (h *EventHandler) ListEvents(ctx context.Context, input *ListEventsInput) ( return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Severity != "" { @@ -191,10 +194,6 @@ func (h *EventHandler) GetEventsByEnvironment(ctx context.Context, input *GetEve return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.EnvironmentID == "" { return nil, huma.Error400BadRequest((&common.EnvironmentIDRequiredError{}).Error()) } @@ -244,10 +243,6 @@ func (h *EventHandler) CreateEvent(ctx context.Context, input *CreateEventInput) } } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - evt, err := h.eventService.CreateEventFromDto(ctx, input.Body) if err != nil { return nil, huma.Error500InternalServerError((&common.EventCreationError{Err: err}).Error()) @@ -267,10 +262,6 @@ func (h *EventHandler) DeleteEvent(ctx context.Context, input *DeleteEventInput) return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.EventID == "" { return nil, huma.Error400BadRequest((&common.EventIDRequiredError{}).Error()) } diff --git a/backend/api/handlers/git_repositories.go b/backend/api/handlers/git_repositories.go index 969b5aa2b4..8aa8f16a85 100644 --- a/backend/api/handlers/git_repositories.go +++ b/backend/api/handlers/git_repositories.go @@ -8,6 +8,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/gitops" @@ -128,6 +129,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitReposList), }, h.ListRepositories) huma.Register(api, huma.Operation{ @@ -141,7 +143,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitReposCreate), }, h.CreateRepository) huma.Register(api, huma.Operation{ @@ -155,6 +157,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitReposRead), }, h.GetRepository) huma.Register(api, huma.Operation{ @@ -168,7 +171,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitReposUpdate), }, h.UpdateRepository) huma.Register(api, huma.Operation{ @@ -182,7 +185,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitReposDelete), }, h.DeleteRepository) huma.Register(api, huma.Operation{ @@ -196,7 +199,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitReposTest), }, h.TestRepository) huma.Register(api, huma.Operation{ @@ -210,6 +213,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitReposRead), }, h.ListBranches) huma.Register(api, huma.Operation{ @@ -223,6 +227,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitReposRead), }, h.BrowseFiles) huma.Register(api, huma.Operation{ @@ -236,7 +241,7 @@ func RegisterGitRepositories(api huma.API, repoService *services.GitRepositorySe {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitReposSync), }, h.SyncRepositories) } @@ -278,10 +283,6 @@ func (h *GitRepositoryHandler) CreateRepository(ctx context.Context, input *Crea return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - actor := models.User{} if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { actor = *currentUser @@ -337,10 +338,6 @@ func (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *Upda return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - actor := models.User{} if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { actor = *currentUser @@ -371,10 +368,6 @@ func (h *GitRepositoryHandler) DeleteRepository(ctx context.Context, input *Dele return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - actor := models.User{} if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { actor = *currentUser @@ -401,10 +394,6 @@ func (h *GitRepositoryHandler) TestRepository(ctx context.Context, input *TestGi return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - actor := models.User{} if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { actor = *currentUser @@ -474,10 +463,6 @@ func (h *GitRepositoryHandler) SyncRepositories(ctx context.Context, input *Sync return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.repoService.SyncRepositories(ctx, input.Body.Repositories); err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositorySyncError{Err: err}).Error()) diff --git a/backend/api/handlers/git_repositories_test.go b/backend/api/handlers/git_repositories_test.go index 73694452c7..c1cd984d6d 100644 --- a/backend/api/handlers/git_repositories_test.go +++ b/backend/api/handlers/git_repositories_test.go @@ -3,59 +3,128 @@ package handlers import ( "context" "net/http" + "net/http/httptest" "testing" "github.com/danielgtaylor/huma/v2" - "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" -) -func TestGitRepositoryHandlers_RequireAdmin(t *testing.T) { - handler := &GitRepositoryHandler{repoService: &services.GitRepositoryService{}} + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" +) +// TestGitRepositoryHandlers_PermissionGating verifies that the mutating +// git-repository operations are gated by their RequirePermission middleware: +// a caller without the required permission gets 403 before the handler runs. +// +// We exercise the full Huma operation stack (registration → middleware → handler) +// so the same path that runs in production is what's under test. +func TestGitRepositoryHandlers_PermissionGating(t *testing.T) { tests := []struct { name string - call func() error + // register installs one operation on the API with its real middleware. + register func(api huma.API) + method string + path string }{ { name: "create repository", - call: func() error { - _, err := handler.CreateRepository(context.Background(), &CreateGitRepositoryInput{}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-create-git-repo", + Method: http.MethodPost, + Path: "/customize/git-repositories", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposCreate), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodPost, + path: "/api/customize/git-repositories", }, { name: "update repository", - call: func() error { - _, err := handler.UpdateRepository(context.Background(), &UpdateGitRepositoryInput{ID: "repo-1"}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-update-git-repo", + Method: http.MethodPut, + Path: "/customize/git-repositories/{id}", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposUpdate), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodPut, + path: "/api/customize/git-repositories/repo-1", }, { name: "delete repository", - call: func() error { - _, err := handler.DeleteRepository(context.Background(), &DeleteGitRepositoryInput{ID: "repo-1"}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-delete-git-repo", + Method: http.MethodDelete, + Path: "/customize/git-repositories/{id}", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposDelete), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodDelete, + path: "/api/customize/git-repositories/repo-1", }, { name: "test repository", - call: func() error { - _, err := handler.TestRepository(context.Background(), &TestGitRepositoryInput{ID: "repo-1", Branch: "main"}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-test-git-repo", + Method: http.MethodPost, + Path: "/customize/git-repositories/{id}/test", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposTest), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodPost, + path: "/api/customize/git-repositories/repo-1/test", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.call() - require.Error(t, err) + router := echo.New() + api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) - var statusErr huma.StatusError - require.ErrorAs(t, err, &statusErr) - require.Equal(t, http.StatusForbidden, statusErr.GetStatus()) - require.Contains(t, statusErr.Error(), "admin access required") + // Attach an empty (deny-all) permission set, simulating an + // authenticated caller who lacks git-repository permissions. + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), humamw.ContextKeyUserPermissions, authz.NewPermissionSet()))) + }) + + tt.register(api) + + req := httptest.NewRequest(tt.method, tt.path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "permission denied") }) } } + +// Compile-time assertion that GitRepositoryService is the type the production +// handler expects; keeps this test honest if the field is ever renamed. +var _ = (*GitRepositoryHandler)(&GitRepositoryHandler{repoService: &services.GitRepositoryService{}}) diff --git a/backend/api/handlers/gitops_syncs.go b/backend/api/handlers/gitops_syncs.go index 5a4e9b1509..ad04311db8 100644 --- a/backend/api/handlers/gitops_syncs.go +++ b/backend/api/handlers/gitops_syncs.go @@ -8,6 +8,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/gitops" @@ -136,6 +137,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsList), }, h.ListSyncs) huma.Register(api, huma.Operation{ @@ -149,7 +151,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsCreate), }, h.CreateSync) huma.Register(api, huma.Operation{ @@ -163,7 +165,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsCreate), }, h.ImportSyncs) huma.Register(api, huma.Operation{ @@ -177,6 +179,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsRead), }, h.GetSync) huma.Register(api, huma.Operation{ @@ -190,7 +193,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsUpdate), }, h.UpdateSync) huma.Register(api, huma.Operation{ @@ -204,7 +207,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsDelete), }, h.DeleteSync) huma.Register(api, huma.Operation{ @@ -218,7 +221,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsSync), }, h.PerformSync) huma.Register(api, huma.Operation{ @@ -232,6 +235,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsRead), }, h.GetStatus) huma.Register(api, huma.Operation{ @@ -245,6 +249,7 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermGitOpsRead), }, h.BrowseFiles) } @@ -283,9 +288,6 @@ func (h *GitOpsSyncHandler) ListSyncs(ctx context.Context, input *ListGitOpsSync // CreateSync creates a new GitOps sync. func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsSyncInput) (*CreateGitOpsSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -316,9 +318,6 @@ func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsS // ImportSyncs imports multiple GitOps syncs. func (h *GitOpsSyncHandler) ImportSyncs(ctx context.Context, input *ImportGitOpsSyncsInput) (*ImportGitOpsSyncsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -368,9 +367,6 @@ func (h *GitOpsSyncHandler) GetSync(ctx context.Context, input *GetGitOpsSyncInp // UpdateSync updates an existing GitOps sync. func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsSyncInput) (*UpdateGitOpsSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -401,9 +397,6 @@ func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsS // DeleteSync deletes a GitOps sync by ID. func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsSyncInput) (*DeleteGitOpsSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -430,9 +423,6 @@ func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsS // PerformSync manually triggers a sync operation. func (h *GitOpsSyncHandler) PerformSync(ctx context.Context, input *PerformSyncInput) (*PerformSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/helpers.go b/backend/api/handlers/helpers.go index 9ba9036365..f7f37d1070 100644 --- a/backend/api/handlers/helpers.go +++ b/backend/api/handlers/helpers.go @@ -6,20 +6,11 @@ import ( "errors" "github.com/danielgtaylor/huma/v2" - humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/remenv" ) -// checkAdminInternal checks if the current user is an admin and returns a 403 error if not. -func checkAdminInternal(ctx context.Context) error { - if !humamw.IsAdminFromContext(ctx) { - return huma.Error403Forbidden("admin access required") - } - return nil -} - // buildPaginationParamsInternal converts query parameters to pagination.QueryParams. // A limit of -1 means "show all items" (no pagination). func buildPaginationParamsInternal(start, limit int, sortCol, sortDir, search string) pagination.QueryParams { diff --git a/backend/api/handlers/image_updates.go b/backend/api/handlers/image_updates.go index 79a4822070..6ec2867734 100644 --- a/backend/api/handlers/image_updates.go +++ b/backend/api/handlers/image_updates.go @@ -6,8 +6,10 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" imagetypes "github.com/getarcaneapp/arcane/types/image" "github.com/getarcaneapp/arcane/types/imageupdate" @@ -85,6 +87,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check image update by reference", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckImageUpdate) huma.Register(api, huma.Operation{ @@ -94,6 +97,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check image update by ID", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckImageUpdateByID) huma.Register(api, huma.Operation{ @@ -103,6 +107,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check image update by ID (POST)", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckImageUpdateByID) huma.Register(api, huma.Operation{ @@ -112,6 +117,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check multiple images", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckMultipleImages) huma.Register(api, huma.Operation{ @@ -121,6 +127,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check all images", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckAllImages) huma.Register(api, huma.Operation{ @@ -130,6 +137,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Get persisted update info for image references", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdateInfoByRefs) huma.Register(api, huma.Operation{ @@ -139,6 +147,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Get update summary", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdateSummary) } diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index 6dbb397d5e..35d533e295 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -12,6 +12,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/image" @@ -169,6 +170,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesList), }, h.ListImages) huma.Register(api, huma.Operation{ @@ -182,6 +184,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesList), }, h.GetImageUsageCounts) huma.Register(api, huma.Operation{ @@ -195,6 +198,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesRead), }, h.GetImage) huma.Register(api, huma.Operation{ @@ -208,7 +212,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesDelete), }, h.RemoveImage) huma.Register(api, huma.Operation{ @@ -222,7 +226,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesPull), }, h.PullImage) huma.Register(api, huma.Operation{ @@ -236,7 +240,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesBuild), }, h.BuildImage) huma.Register(api, huma.Operation{ @@ -250,6 +254,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesList), }, h.ListImageBuilds) huma.Register(api, huma.Operation{ @@ -263,6 +268,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesRead), }, h.GetImageBuild) huma.Register(api, huma.Operation{ @@ -276,7 +282,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesPrune), }, h.PruneImages) huma.Register(api, huma.Operation{ @@ -307,7 +313,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesUpload), }, h.UploadImage) } @@ -389,9 +395,6 @@ func (h *ImageHandler) GetImage(ctx context.Context, input *GetImageInput) (*Get // RemoveImage removes a Docker image. func (h *ImageHandler) RemoveImage(ctx context.Context, input *RemoveImageInput) (*RemoveImageOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -417,9 +420,6 @@ func (h *ImageHandler) RemoveImage(ctx context.Context, input *RemoveImageInput) // PullImage pulls a Docker image with streaming progress. func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -456,9 +456,6 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h // BuildImage builds a Docker image with streaming progress. func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.buildService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -562,9 +559,6 @@ func (h *ImageHandler) GetImageBuild(ctx context.Context, input *GetImageBuildIn // PruneImages removes unused Docker images. func (h *ImageHandler) PruneImages(ctx context.Context, input *PruneImagesInput) (*PruneImagesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -687,9 +681,6 @@ func (h *ImageHandler) GetImageUsageCounts(ctx context.Context, input *GetImageU // UploadImage uploads a Docker image from a tar archive. func (h *ImageHandler) UploadImage(ctx context.Context, input *UploadImageInput) (*UploadImageOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil || h.settingsService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/job_schedules.go b/backend/api/handlers/job_schedules.go index cf00e585fb..454e2c108c 100644 --- a/backend/api/handlers/job_schedules.go +++ b/backend/api/handlers/job_schedules.go @@ -7,6 +7,7 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/jobschedule" ) @@ -62,6 +63,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.Get) huma.Register(api, huma.Operation{ @@ -75,7 +77,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.Update) huma.Register(api, huma.Operation{ @@ -89,6 +91,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.ListJobs) huma.Register(api, huma.Operation{ @@ -102,7 +105,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.RunJob) } @@ -136,9 +139,6 @@ func (h *JobSchedulesHandler) ListJobs(ctx context.Context, input *ListJobsInput } func (h *JobSchedulesHandler) RunJob(ctx context.Context, input *RunJobInput) (*RunJobOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.jobService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -188,9 +188,6 @@ func (h *JobSchedulesHandler) Get(ctx context.Context, input *GetJobSchedulesInp } func (h *JobSchedulesHandler) Update(ctx context.Context, input *UpdateJobSchedulesInput) (*UpdateJobSchedulesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.jobService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/networks.go b/backend/api/handlers/networks.go index adb89db55a..f0a80c1187 100644 --- a/backend/api/handlers/networks.go +++ b/backend/api/handlers/networks.go @@ -12,6 +12,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" @@ -145,6 +146,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "List networks", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksList), }, h.ListNetworks) huma.Register(api, huma.Operation{ @@ -154,6 +156,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Network counts", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksList), }, h.GetNetworkCounts) huma.Register(api, huma.Operation{ @@ -163,7 +166,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Create network", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNetworksCreate), }, h.CreateNetwork) huma.Register(api, huma.Operation{ @@ -173,6 +176,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Get network topology", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksRead), }, h.GetNetworkTopology) huma.Register(api, huma.Operation{ @@ -182,6 +186,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Get network", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksRead), }, h.GetNetwork) huma.Register(api, huma.Operation{ @@ -191,7 +196,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Delete network", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNetworksDelete), }, h.DeleteNetwork) huma.Register(api, huma.Operation{ @@ -201,7 +206,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Prune networks", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNetworksPrune), }, h.PruneNetworks) } @@ -266,9 +271,6 @@ func (h *NetworkHandler) GetNetworkCounts(ctx context.Context, input *GetNetwork } func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetworkInput) (*CreateNetworkOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized("not authenticated") @@ -398,9 +400,6 @@ func (h *NetworkHandler) GetNetworkTopology(ctx context.Context, input *GetNetwo } func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetworkInput) (*DeleteNetworkOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized("not authenticated") @@ -419,9 +418,6 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork } func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworksInput) (*PruneNetworksOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } report, err := h.networkService.PruneNetworks(ctx) if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkPruneError{Err: err}).Error()) diff --git a/backend/api/handlers/notifications.go b/backend/api/handlers/notifications.go index 9efe5b09dd..353fad9e4c 100644 --- a/backend/api/handlers/notifications.go +++ b/backend/api/handlers/notifications.go @@ -12,6 +12,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/notification" ) @@ -111,7 +112,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Get all notification settings", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.GetAllNotificationSettings) huma.Register(api, huma.Operation{ @@ -121,7 +122,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Get notification settings by provider", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.GetNotificationSettings) huma.Register(api, huma.Operation{ @@ -131,7 +132,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Create or update notification settings", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.CreateOrUpdateNotificationSettings) huma.Register(api, huma.Operation{ @@ -141,7 +142,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Delete notification settings", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.DeleteNotificationSettings) huma.Register(api, huma.Operation{ @@ -151,7 +152,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Test notification", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.TestNotification) huma.Register(api, huma.Operation{ @@ -161,6 +162,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Dispatch notification from remote agent to manager", Tags: []string{"Notifications"}, Security: []map[string][]string{{"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.DispatchNotification) } @@ -172,9 +174,6 @@ func (h *NotificationHandler) rejectIfAgentModeInternal() error { } func (h *NotificationHandler) GetAllNotificationSettings(ctx context.Context, input *GetAllNotificationSettingsInput) (*GetAllNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -197,9 +196,6 @@ func (h *NotificationHandler) GetAllNotificationSettings(ctx context.Context, in } func (h *NotificationHandler) GetNotificationSettings(ctx context.Context, input *GetNotificationSettingsInput) (*GetNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -221,9 +217,6 @@ func (h *NotificationHandler) GetNotificationSettings(ctx context.Context, input } func (h *NotificationHandler) CreateOrUpdateNotificationSettings(ctx context.Context, input *CreateOrUpdateNotificationSettingsInput) (*CreateOrUpdateNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -253,9 +246,6 @@ func (h *NotificationHandler) CreateOrUpdateNotificationSettings(ctx context.Con } func (h *NotificationHandler) DeleteNotificationSettings(ctx context.Context, input *DeleteNotificationSettingsInput) (*DeleteNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -274,9 +264,6 @@ func (h *NotificationHandler) DeleteNotificationSettings(ctx context.Context, in } func (h *NotificationHandler) TestNotification(ctx context.Context, input *TestNotificationInput) (*TestNotificationOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } diff --git a/backend/api/handlers/oidc.go b/backend/api/handlers/oidc.go index c98175eafa..39d090d7df 100644 --- a/backend/api/handlers/oidc.go +++ b/backend/api/handlers/oidc.go @@ -7,19 +7,24 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" httputils "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/types/auth" - "github.com/getarcaneapp/arcane/types/user" + roletypes "github.com/getarcaneapp/arcane/types/role" ) -// OidcHandler handles OIDC authentication endpoints. +// OidcHandler handles OIDC authentication endpoints, plus OIDC group → role +// mapping management (since mappings only make sense in the OIDC context). type OidcHandler struct { authService *services.AuthService oidcService *services.OidcService + roleService *services.RoleService + userService *services.UserService config *config.Config } @@ -86,13 +91,59 @@ type ExchangeDeviceTokenOutput struct { Body auth.OidcDeviceTokenResponse } +// --- OIDC role mapping I/O --- + +type ListOidcRoleMappingsInput struct{} + +type ListOidcRoleMappingsOutput struct { + Body struct { + Success bool `json:"success"` + Data []roletypes.OidcRoleMapping `json:"data"` + } +} + +type CreateOidcRoleMappingInput struct { + Body roletypes.CreateOidcRoleMapping +} + +type CreateOidcRoleMappingOutput struct { + Body struct { + Success bool `json:"success"` + Data roletypes.OidcRoleMapping `json:"data"` + } +} + +type UpdateOidcRoleMappingInput struct { + ID string `path:"id" doc:"Mapping ID"` + Body roletypes.UpdateOidcRoleMapping +} + +type UpdateOidcRoleMappingOutput struct { + Body struct { + Success bool `json:"success"` + Data roletypes.OidcRoleMapping `json:"data"` + } +} + +type DeleteOidcRoleMappingInput struct { + ID string `path:"id" doc:"Mapping ID"` +} + +type DeleteOidcRoleMappingOutput struct { + Body struct { + Success bool `json:"success"` + Message string `json:"message"` + } +} + // ============================================================================ // Registration // ============================================================================ -// RegisterOidc registers all OIDC authentication endpoints using Huma. -func RegisterOidc(api huma.API, authService *services.AuthService, oidcService *services.OidcService, cfg *config.Config) { - h := &OidcHandler{authService: authService, oidcService: oidcService, config: cfg} +// RegisterOidc registers all OIDC authentication endpoints (plus the OIDC +// group → role mapping CRUD) using Huma. +func RegisterOidc(api huma.API, authService *services.AuthService, oidcService *services.OidcService, roleService *services.RoleService, userService *services.UserService, cfg *config.Config) { + h := &OidcHandler{authService: authService, oidcService: oidcService, roleService: roleService, userService: userService, config: cfg} huma.Register(api, huma.Operation{ OperationID: "get-oidc-status", @@ -153,6 +204,49 @@ func RegisterOidc(api huma.API, authService *services.AuthService, oidcService * Tags: []string{"OIDC"}, Security: []map[string][]string{}, }, h.ExchangeDeviceToken) + + // --- OIDC role mapping endpoints --- + + huma.Register(api, huma.Operation{ + OperationID: "list-oidc-role-mappings", + Method: http.MethodGet, + Path: "/oidc/role-mappings", + Summary: "List OIDC group → role mappings", + Description: "Returns every mapping. On each OIDC login the user's group claim is matched against ClaimValue and matching rows become source='oidc' role assignments.", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.ListOidcRoleMappings) + + huma.Register(api, huma.Operation{ + OperationID: "create-oidc-role-mapping", + Method: http.MethodPost, + Path: "/oidc/role-mappings", + Summary: "Create an OIDC role mapping", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.CreateOidcRoleMapping) + + huma.Register(api, huma.Operation{ + OperationID: "update-oidc-role-mapping", + Method: http.MethodPut, + Path: "/oidc/role-mappings/{id}", + Summary: "Update an OIDC role mapping", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.UpdateOidcRoleMapping) + + huma.Register(api, huma.Operation{ + OperationID: "delete-oidc-role-mapping", + Method: http.MethodDelete, + Path: "/oidc/role-mappings/{id}", + Summary: "Delete an OIDC role mapping", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.DeleteOidcRoleMapping) } // ============================================================================ @@ -299,6 +393,11 @@ func (h *OidcHandler) HandleOidcCallback(ctx context.Context, input *HandleOidcC setCookies = append(setCookies, cookie.BuildTokenCookieStringFor(maxAge, tokenPair.AccessToken, cookie.SecureCookieFromContext(ctx))) } + userDto, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) + } + return &HandleOidcCallbackOutput{ SetCookie: setCookies, Body: auth.OidcCallbackResponse{ @@ -306,14 +405,7 @@ func (h *OidcHandler) HandleOidcCallback(ctx context.Context, input *HandleOidcC Token: tokenPair.AccessToken, RefreshToken: tokenPair.RefreshToken, ExpiresAt: tokenPair.ExpiresAt, - User: user.User{ - ID: userModel.ID, - Username: userModel.Username, - DisplayName: userModel.DisplayName, - Email: userModel.Email, - Roles: userModel.Roles, - OidcSubjectId: userModel.OidcSubjectId, - }, + User: userDto, }, }, nil } @@ -381,6 +473,11 @@ func (h *OidcHandler) ExchangeDeviceToken(ctx context.Context, input *ExchangeDe tokenCookie := cookie.BuildTokenCookieStringFor(maxAge, tokenPair.AccessToken, cookie.SecureCookieFromContext(ctx)) + userDto, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) + } + return &ExchangeDeviceTokenOutput{ SetCookie: []string{tokenCookie}, Body: auth.OidcDeviceTokenResponse{ @@ -388,14 +485,93 @@ func (h *OidcHandler) ExchangeDeviceToken(ctx context.Context, input *ExchangeDe Token: tokenPair.AccessToken, RefreshToken: tokenPair.RefreshToken, ExpiresAt: tokenPair.ExpiresAt, - User: user.User{ - ID: userModel.ID, - Username: userModel.Username, - DisplayName: userModel.DisplayName, - Email: userModel.Email, - Roles: userModel.Roles, - OidcSubjectId: userModel.OidcSubjectId, - }, + User: userDto, }, }, nil } + +// ============================================================================ +// OIDC Role Mapping Handlers +// ============================================================================ + +func (h *OidcHandler) ListOidcRoleMappings(ctx context.Context, _ *ListOidcRoleMappingsInput) (*ListOidcRoleMappingsOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + rows, err := h.roleService.ListOidcMappings(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list mappings: " + err.Error()) + } + out := &ListOidcRoleMappingsOutput{} + out.Body.Success = true + out.Body.Data = make([]roletypes.OidcRoleMapping, len(rows)) + for i := range rows { + out.Body.Data[i] = toOidcMappingDTO(&rows[i]) + } + return out, nil +} + +func (h *OidcHandler) CreateOidcRoleMapping(ctx context.Context, input *CreateOidcRoleMappingInput) (*CreateOidcRoleMappingOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + mapping, err := h.roleService.CreateOidcMapping(ctx, input.Body.ClaimValue, input.Body.RoleID, input.Body.EnvironmentID) + if err != nil { + return nil, huma.Error500InternalServerError("failed to create mapping: " + err.Error()) + } + out := &CreateOidcRoleMappingOutput{} + out.Body.Success = true + out.Body.Data = toOidcMappingDTO(mapping) + return out, nil +} + +func (h *OidcHandler) UpdateOidcRoleMapping(ctx context.Context, input *UpdateOidcRoleMappingInput) (*UpdateOidcRoleMappingOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + mapping, err := h.roleService.UpdateOidcMapping(ctx, input.ID, input.Body.ClaimValue, input.Body.RoleID, input.Body.EnvironmentID) + if err != nil { + if common.IsOidcMappingNotFoundError(err) { + return nil, huma.Error404NotFound("mapping not found") + } + if common.IsOidcMappingEnvManagedError(err) { + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to update mapping: " + err.Error()) + } + out := &UpdateOidcRoleMappingOutput{} + out.Body.Success = true + out.Body.Data = toOidcMappingDTO(mapping) + return out, nil +} + +func (h *OidcHandler) DeleteOidcRoleMapping(ctx context.Context, input *DeleteOidcRoleMappingInput) (*DeleteOidcRoleMappingOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if err := h.roleService.DeleteOidcMapping(ctx, input.ID); err != nil { + if common.IsOidcMappingNotFoundError(err) { + return nil, huma.Error404NotFound("mapping not found") + } + if common.IsOidcMappingEnvManagedError(err) { + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to delete mapping: " + err.Error()) + } + out := &DeleteOidcRoleMappingOutput{} + out.Body.Success = true + out.Body.Message = "mapping deleted" + return out, nil +} + +func toOidcMappingDTO(m *models.OidcRoleMapping) roletypes.OidcRoleMapping { + return roletypes.OidcRoleMapping{ + ID: m.ID, + ClaimValue: m.ClaimValue, + RoleID: m.RoleID, + EnvironmentID: m.EnvironmentID, + Source: m.Source, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} diff --git a/backend/api/handlers/ports.go b/backend/api/handlers/ports.go index 99894bae9f..8523341ae9 100644 --- a/backend/api/handlers/ports.go +++ b/backend/api/handlers/ports.go @@ -6,7 +6,9 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" porttypes "github.com/getarcaneapp/arcane/types/port" @@ -45,6 +47,7 @@ func RegisterPorts(api huma.API, portSvc *services.PortService) { Summary: "List port mappings", Tags: []string{"Ports"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersList), }, h.ListPorts) } diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index bebb564912..d23125191f 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -12,6 +12,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" projects "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -217,6 +218,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsList), }, h.ListProjects) huma.Register(api, huma.Operation{ @@ -230,6 +232,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsList), }, h.GetProjectStatusCounts) huma.Register(api, huma.Operation{ @@ -243,7 +246,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.DeployProject) huma.Register(api, huma.Operation{ @@ -257,7 +260,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDown), }, h.DownProject) huma.Register(api, huma.Operation{ @@ -271,7 +274,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsCreate), }, h.CreateProject) huma.Register(api, huma.Operation{ @@ -285,6 +288,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProject) huma.Register(api, huma.Operation{ @@ -298,6 +302,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectCompose) huma.Register(api, huma.Operation{ @@ -311,6 +316,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectFiles) huma.Register(api, huma.Operation{ @@ -324,6 +330,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectRuntime) huma.Register(api, huma.Operation{ @@ -337,6 +344,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectUpdates) huma.Register(api, huma.Operation{ @@ -350,6 +358,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectFile) huma.Register(api, huma.Operation{ @@ -363,7 +372,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.RedeployProject) huma.Register(api, huma.Operation{ @@ -377,7 +386,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDelete), }, h.DestroyProject) huma.Register(api, huma.Operation{ @@ -391,7 +400,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsUpdate), }, h.UpdateProject) huma.Register(api, huma.Operation{ @@ -405,7 +414,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsUpdate), }, h.UpdateProjectInclude) huma.Register(api, huma.Operation{ @@ -419,7 +428,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRestart), }, h.RestartProject) huma.Register(api, huma.Operation{ @@ -433,7 +442,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsArchive), }, h.ArchiveProject) huma.Register(api, huma.Operation{ @@ -447,7 +456,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsArchive), }, h.UnarchiveProject) huma.Register(api, huma.Operation{ @@ -461,7 +470,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.PullProjectImages) huma.Register(api, huma.Operation{ @@ -475,7 +484,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.BuildProjectImages) } @@ -564,9 +573,6 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP // DeployProject deploys a Docker Compose project. func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProjectInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -613,9 +619,6 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject // DownProject brings down a Docker Compose project. func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInput) (*DownProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -645,9 +648,6 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu // CreateProject creates a new Docker Compose project. func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProjectInput) (*CreateProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -795,9 +795,6 @@ func (h *ProjectHandler) GetProjectFile(ctx context.Context, input *GetProjectFi // RedeployProject redeploys a Docker Compose project. func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployProjectInput) (*RedeployProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -831,9 +828,6 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro // DestroyProject destroys a Docker Compose project. func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProjectInput) (*DestroyProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -873,9 +867,6 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje // UpdateProject updates a Docker Compose project. func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProjectInput) (*UpdateProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -908,9 +899,6 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject // UpdateProjectInclude updates an include file within a project. func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *UpdateProjectIncludeInput) (*UpdateProjectIncludeOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -943,9 +931,6 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update // RestartProject restarts all containers in a project. func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProjectInput) (*RestartProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -978,9 +963,6 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje } func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProjectInput) (*ArchiveProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1011,9 +993,6 @@ func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProje } func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveProjectInput) (*UnarchiveProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1041,9 +1020,6 @@ func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveP // PullProjectImages pulls all images for a project with streaming progress. func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProjectImagesInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1089,9 +1065,6 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje // BuildProjectImages builds compose services with build directives. func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildProjectInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/remenv_handlers_test.go b/backend/api/handlers/remenv_handlers_test.go index 6b796eeb13..c771c4816f 100644 --- a/backend/api/handlers/remenv_handlers_test.go +++ b/backend/api/handlers/remenv_handlers_test.go @@ -13,6 +13,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/env" "github.com/getarcaneapp/arcane/types/jobschedule" @@ -22,10 +23,10 @@ import ( "gorm.io/gorm" ) -// adminTestContextInternal returns a context with the admin flag set, suitable for -// unit-testing handlers that call checkAdminInternal directly. +// adminTestContextInternal returns a context with a sudo PermissionSet attached, +// suitable for unit-testing handlers that gate via RequirePermission middleware. func adminTestContextInternal() context.Context { - return context.WithValue(context.Background(), humamiddleware.ContextKeyUserIsAdmin, true) + return context.WithValue(context.Background(), humamiddleware.ContextKeyUserPermissions, authz.SudoPermissionSet()) } func setupRemoteHandlerEnvironmentServiceInternal(t *testing.T, server *httptest.Server) *services.EnvironmentService { diff --git a/backend/api/handlers/roles.go b/backend/api/handlers/roles.go new file mode 100644 index 0000000000..307c123eb6 --- /dev/null +++ b/backend/api/handlers/roles.go @@ -0,0 +1,421 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/types/base" + roletypes "github.com/getarcaneapp/arcane/types/role" +) + +type RoleHandler struct { + roleService *services.RoleService +} + +// ---------- I/O wrappers ---------- + +type RolePaginatedResponse struct { + Success bool `json:"success"` + Data []roletypes.Role `json:"data"` + Pagination base.PaginationResponse `json:"pagination"` +} + +type ListRolesInput struct { + Search string `query:"search" doc:"Search by role name or description"` + Sort string `query:"sort" doc:"Column to sort by"` + Order string `query:"order" default:"asc" doc:"Sort direction (asc or desc)"` + Start int `query:"start" default:"0" doc:"Start index for pagination"` + Limit int `query:"limit" default:"20" doc:"Items per page"` +} + +type ListRolesOutput struct { + Body RolePaginatedResponse +} + +type GetRoleInput struct { + ID string `path:"id" doc:"Role ID"` +} + +type GetRoleOutput struct { + Body base.ApiResponse[roletypes.Role] +} + +type CreateRoleInput struct { + Body roletypes.CreateRole +} + +type CreateRoleOutput struct { + Body base.ApiResponse[roletypes.Role] +} + +type UpdateRoleInput struct { + ID string `path:"id" doc:"Role ID"` + Body roletypes.UpdateRole +} + +type UpdateRoleOutput struct { + Body base.ApiResponse[roletypes.Role] +} + +type DeleteRoleInput struct { + ID string `path:"id" doc:"Role ID"` +} + +type DeleteRoleOutput struct { + Body base.ApiResponse[base.MessageResponse] +} + +type PermissionsManifestOutput struct { + Body base.ApiResponse[roletypes.PermissionsManifest] +} + +type ListUserRoleAssignmentsInput struct { + UserID string `path:"userId" doc:"User ID"` +} + +type ListUserRoleAssignmentsOutput struct { + Body base.ApiResponse[[]roletypes.RoleAssignment] +} + +type SetUserRoleAssignmentsInput struct { + UserID string `path:"userId" doc:"User ID"` + Body roletypes.SetUserAssignments +} + +type SetUserRoleAssignmentsOutput struct { + Body base.ApiResponse[[]roletypes.RoleAssignment] +} + +// ---------- Registration ---------- + +func RegisterRoles(api huma.API, roleService *services.RoleService) { + h := &RoleHandler{roleService: roleService} + + huma.Register(api, huma.Operation{ + OperationID: "list-roles", + Method: http.MethodGet, + Path: "/roles", + Summary: "List roles", + Description: "Get a paginated list of roles (built-in + custom)", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermRolesList), + }, h.ListRoles) + + huma.Register(api, huma.Operation{ + OperationID: "get-role", + Method: http.MethodGet, + Path: "/roles/{id}", + Summary: "Get a role", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermRolesRead), + }, h.GetRole) + + huma.Register(api, huma.Operation{ + OperationID: "create-role", + Method: http.MethodPost, + Path: "/roles", + Summary: "Create a custom role", + Description: "Built-in roles cannot be created via this endpoint; only custom roles are accepted. Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.CreateRole) + + huma.Register(api, huma.Operation{ + OperationID: "update-role", + Method: http.MethodPut, + Path: "/roles/{id}", + Summary: "Update a custom role", + Description: "Built-in roles are read-only and return 403 on update. Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.UpdateRole) + + huma.Register(api, huma.Operation{ + OperationID: "delete-role", + Method: http.MethodDelete, + Path: "/roles/{id}", + Summary: "Delete a custom role", + Description: "Built-in roles are protected; deleting cascades all user assignments. Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.DeleteRole) + + huma.Register(api, huma.Operation{ + OperationID: "get-permissions-manifest", + Method: http.MethodGet, + Path: "/roles/available-permissions", + Summary: "Get the permission manifest", + Description: "Returns every permission the server recognizes, grouped by resource. Used by the role editor UI.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermRolesRead), + }, h.GetPermissionsManifest) + + huma.Register(api, huma.Operation{ + OperationID: "list-user-role-assignments", + Method: http.MethodGet, + Path: "/users/{userId}/role-assignments", + Summary: "List a user's role assignments", + Description: "Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.ListUserRoleAssignments) + + huma.Register(api, huma.Operation{ + OperationID: "set-user-role-assignments", + Method: http.MethodPut, + Path: "/users/{userId}/role-assignments", + Summary: "Replace a user's manual role assignments", + Description: "Replaces every source='manual' assignment for the user. source='oidc' assignments are not touched. Reserved for global admins; enforces the last-admin guard.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.SetUserRoleAssignments) +} + +// ---------- Handler implementations ---------- + +func (h *RoleHandler) ListRoles(ctx context.Context, input *ListRolesInput) (*ListRolesOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) + roles, paginationResp, err := h.roleService.ListRoles(ctx, params) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list roles: " + err.Error()) + } + dtos := make([]roletypes.Role, len(roles)) + for i := range roles { + dtos[i] = h.toRoleDTO(ctx, &roles[i]) + } + return &ListRolesOutput{ + Body: RolePaginatedResponse{ + Success: true, + Data: dtos, + Pagination: base.PaginationResponse{ + TotalPages: paginationResp.TotalPages, + TotalItems: paginationResp.TotalItems, + CurrentPage: paginationResp.CurrentPage, + ItemsPerPage: paginationResp.ItemsPerPage, + GrandTotalItems: paginationResp.GrandTotalItems, + }, + }, + }, nil +} + +func (h *RoleHandler) GetRole(ctx context.Context, input *GetRoleInput) (*GetRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + role, err := h.roleService.GetRole(ctx, input.ID) + if err != nil { + if common.IsRoleNotFoundError(err) { + return nil, huma.Error404NotFound("role not found") + } + return nil, huma.Error500InternalServerError("failed to get role: " + err.Error()) + } + return &GetRoleOutput{ + Body: base.ApiResponse[roletypes.Role]{Success: true, Data: h.toRoleDTO(ctx, role)}, + }, nil +} + +func (h *RoleHandler) CreateRole(ctx context.Context, input *CreateRoleInput) (*CreateRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + callerPS, _ := humamw.PermissionsFromContext(ctx) + if err := h.roleService.ValidatePermissionsAgainstCaller(callerPS, input.Body.Permissions); err != nil { + switch { + case common.IsUnknownPermissionError(err): + return nil, huma.Error400BadRequest(err.Error()) + case common.IsRolePermissionEscalationError(err): + return nil, huma.Error403Forbidden(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to validate role permissions: " + err.Error()) + } + role, err := h.roleService.CreateRole(ctx, input.Body.Name, input.Body.Description, input.Body.Permissions) + if err != nil { + if common.IsRoleNameTakenError(err) { + return nil, huma.Error409Conflict("role name already in use") + } + if common.IsUnknownPermissionError(err) { + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to create role: " + err.Error()) + } + return &CreateRoleOutput{ + Body: base.ApiResponse[roletypes.Role]{Success: true, Data: h.toRoleDTO(ctx, role)}, + }, nil +} + +func (h *RoleHandler) UpdateRole(ctx context.Context, input *UpdateRoleInput) (*UpdateRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + callerPS, _ := humamw.PermissionsFromContext(ctx) + if err := h.roleService.ValidatePermissionsAgainstCaller(callerPS, input.Body.Permissions); err != nil { + switch { + case common.IsUnknownPermissionError(err): + return nil, huma.Error400BadRequest(err.Error()) + case common.IsRolePermissionEscalationError(err): + return nil, huma.Error403Forbidden(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to validate role permissions: " + err.Error()) + } + role, err := h.roleService.UpdateRole(ctx, input.ID, input.Body.Name, input.Body.Description, input.Body.Permissions) + if err != nil { + switch { + case common.IsRoleNotFoundError(err): + return nil, huma.Error404NotFound("role not found") + case common.IsRoleBuiltInError(err): + return nil, huma.Error403Forbidden("built-in roles cannot be modified") + case common.IsRoleNameTakenError(err): + return nil, huma.Error409Conflict("role name already in use") + case common.IsUnknownPermissionError(err): + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to update role: " + err.Error()) + } + return &UpdateRoleOutput{ + Body: base.ApiResponse[roletypes.Role]{Success: true, Data: h.toRoleDTO(ctx, role)}, + }, nil +} + +func (h *RoleHandler) DeleteRole(ctx context.Context, input *DeleteRoleInput) (*DeleteRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if err := h.roleService.DeleteRole(ctx, input.ID); err != nil { + switch { + case common.IsRoleNotFoundError(err): + return nil, huma.Error404NotFound("role not found") + case common.IsRoleBuiltInError(err): + return nil, huma.Error403Forbidden("built-in roles cannot be deleted") + case common.IsNoGlobalAdminRemainsError(err): + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to delete role: " + err.Error()) + } + return &DeleteRoleOutput{ + Body: base.ApiResponse[base.MessageResponse]{Success: true, Data: base.MessageResponse{Message: "role deleted"}}, + }, nil +} + +func (h *RoleHandler) GetPermissionsManifest(_ context.Context, _ *struct{}) (*PermissionsManifestOutput, error) { + return &PermissionsManifestOutput{ + Body: base.ApiResponse[roletypes.PermissionsManifest]{Success: true, Data: buildPermissionsManifestInternal()}, + }, nil +} + +func (h *RoleHandler) ListUserRoleAssignments(ctx context.Context, input *ListUserRoleAssignmentsInput) (*ListUserRoleAssignmentsOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + rows, err := h.roleService.ListUserAssignments(ctx, input.UserID) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list assignments: " + err.Error()) + } + dtos := make([]roletypes.RoleAssignment, len(rows)) + for i := range rows { + dtos[i] = toAssignmentDTOInternal(&rows[i]) + } + return &ListUserRoleAssignmentsOutput{ + Body: base.ApiResponse[[]roletypes.RoleAssignment]{Success: true, Data: dtos}, + }, nil +} + +func (h *RoleHandler) SetUserRoleAssignments(ctx context.Context, input *SetUserRoleAssignmentsInput) (*SetUserRoleAssignmentsOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + desired := make([]models.UserRoleAssignment, len(input.Body.Assignments)) + for i, a := range input.Body.Assignments { + desired[i] = models.UserRoleAssignment{RoleID: a.RoleID, EnvironmentID: a.EnvironmentID} + } + if err := h.roleService.SetUserAssignments(ctx, input.UserID, desired); err != nil { + switch { + case common.IsInvalidRoleAssignmentError(err): + return nil, huma.Error400BadRequest(err.Error()) + case common.IsNoGlobalAdminRemainsError(err): + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to set assignments: " + err.Error()) + } + rows, err := h.roleService.ListUserAssignments(ctx, input.UserID) + if err != nil { + return nil, huma.Error500InternalServerError("failed to read back assignments: " + err.Error()) + } + dtos := make([]roletypes.RoleAssignment, len(rows)) + for i := range rows { + dtos[i] = toAssignmentDTOInternal(&rows[i]) + } + return &SetUserRoleAssignmentsOutput{ + Body: base.ApiResponse[[]roletypes.RoleAssignment]{Success: true, Data: dtos}, + }, nil +} + +// ---------- DTO mappers ---------- + +func (h *RoleHandler) toRoleDTO(ctx context.Context, r *models.Role) roletypes.Role { + out := roletypes.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Permissions: []string(r.Permissions), + BuiltIn: r.BuiltIn, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if count, err := h.roleService.CountUsersAssignedToRole(ctx, r.ID); err == nil { + out.AssignedUserCount = count + } + return out +} + +func toAssignmentDTOInternal(r *models.UserRoleAssignment) roletypes.RoleAssignment { + return roletypes.RoleAssignment{ + ID: r.ID, + UserID: r.UserID, + RoleID: r.RoleID, + EnvironmentID: r.EnvironmentID, + Source: r.Source, + CreatedAt: r.CreatedAt, + } +} + +// buildPermissionsManifestInternal maps the authz-owned permission catalog into +// the public API manifest shape used by the frontend role editor. +func buildPermissionsManifestInternal() roletypes.PermissionsManifest { + catalog := authz.PermissionCatalog() + resources := make([]roletypes.PermissionResource, len(catalog)) + for i, resource := range catalog { + actions := make([]roletypes.PermissionAction, len(resource.Actions)) + for j, action := range resource.Actions { + actions[j] = roletypes.PermissionAction{ + Key: action.Key, + Permission: action.Permission, + Label: action.Label, + Description: action.Description, + } + } + resources[i] = roletypes.PermissionResource{ + Key: resource.Key, + Label: resource.Label, + Scope: resource.Scope, + Actions: actions, + } + } + return roletypes.PermissionsManifest{Resources: resources} +} diff --git a/backend/api/handlers/settings.go b/backend/api/handlers/settings.go index 47e9c7dcc7..083a413968 100644 --- a/backend/api/handlers/settings.go +++ b/backend/api/handlers/settings.go @@ -15,6 +15,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" @@ -141,6 +142,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead), }, h.GetSettings) huma.Register(api, huma.Operation{ @@ -154,7 +156,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSettingsWrite), }, h.UpdateSettings) // Top-level settings endpoints (not environment-scoped) @@ -169,7 +171,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead), }, h.Search) huma.Register(api, huma.Operation{ @@ -183,7 +185,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead), }, h.GetCategories) } @@ -264,7 +266,8 @@ func (h *SettingsHandler) GetSettings(ctx context.Context, input *GetSettingsInp return nil, huma.Error500InternalServerError("service not available") } - isAdmin := humamw.IsAdminFromContext(ctx) + ps, _ := humamw.PermissionsFromContext(ctx) + isAdmin := ps.IsGlobalAdmin() if input.EnvironmentID != "0" { if h.environmentService == nil { @@ -297,10 +300,6 @@ func (h *SettingsHandler) UpdateSettings(ctx context.Context, input *UpdateSetti return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.validateSettingsUpdateInput(input.Body); err != nil { return nil, err } @@ -401,10 +400,11 @@ func hasAuthSettingsUpdateInternal(req settings.Update) bool { req.AuthSessionTimeout != nil || req.AuthPasswordPolicy != nil || req.OidcClientId != nil || req.OidcClientSecret != nil || req.OidcIssuerUrl != nil || - req.OidcScopes != nil || req.OidcAdminClaim != nil || - req.OidcAdminValue != nil || req.OidcMergeAccounts != nil || + req.OidcScopes != nil || + req.OidcMergeAccounts != nil || req.OidcSkipTlsVerify != nil || req.OidcAutoRedirectToProvider != nil || - req.OidcProviderName != nil || req.OidcProviderLogoUrl != nil + req.OidcProviderName != nil || req.OidcProviderLogoUrl != nil || + req.OidcGroupsClaim != nil } // Search searches settings by query. @@ -413,10 +413,6 @@ func (h *SettingsHandler) Search(ctx context.Context, input *SearchSettingsInput return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if strings.TrimSpace(input.Body.Query) == "" { return nil, huma.Error400BadRequest((&common.QueryParameterRequiredError{}).Error()) } @@ -431,10 +427,6 @@ func (h *SettingsHandler) GetCategories(ctx context.Context, input *struct{}) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - categories := h.settingsSearchService.GetSettingsCategories() return &GetCategoriesOutput{Body: categories}, nil } diff --git a/backend/api/handlers/swarm.go b/backend/api/handlers/swarm.go index 3778d34350..ddf3f9d0a9 100644 --- a/backend/api/handlers/swarm.go +++ b/backend/api/handlers/swarm.go @@ -14,6 +14,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" @@ -518,59 +519,59 @@ func RegisterSwarm(api huma.API, swarmSvc *services.SwarmService, environmentSvc cfg: cfg, } - huma.Register(api, huma.Operation{OperationID: "list-swarm-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/services", Summary: "List swarm services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListServices) - huma.Register(api, huma.Operation{OperationID: "get-swarm-service", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Get swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetService) - huma.Register(api, huma.Operation{OperationID: "create-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services", Summary: "Create swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateService) - huma.Register(api, huma.Operation{OperationID: "update-swarm-service", Method: http.MethodPut, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Update swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateService) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-service", Method: http.MethodDelete, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Delete swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteService) - huma.Register(api, huma.Operation{OperationID: "list-swarm-service-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}/tasks", Summary: "List tasks for a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListServiceTasks) - huma.Register(api, huma.Operation{OperationID: "rollback-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/rollback", Summary: "Rollback a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.RollbackService) - huma.Register(api, huma.Operation{OperationID: "scale-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/scale", Summary: "Scale a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.ScaleService) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-nodes", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes", Summary: "List swarm nodes", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListNodes) - huma.Register(api, huma.Operation{OperationID: "get-swarm-node", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Get swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNode) - huma.Register(api, huma.Operation{OperationID: "get-swarm-node-agent-deployment", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/agent/deployment", Summary: "Get swarm node agent deployment snippets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetNodeAgentDeployment) - huma.Register(api, huma.Operation{OperationID: "update-swarm-node", Method: http.MethodPatch, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Update swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateNode) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-node", Method: http.MethodDelete, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Delete swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteNode) - huma.Register(api, huma.Operation{OperationID: "promote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/promote", Summary: "Promote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.PromoteNode) - huma.Register(api, huma.Operation{OperationID: "demote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/demote", Summary: "Demote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DemoteNode) - huma.Register(api, huma.Operation{OperationID: "list-swarm-node-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}/tasks", Summary: "List tasks for a swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListNodeTasks) - huma.Register(api, huma.Operation{OperationID: "get-swarm-node-identity", Method: http.MethodGet, Path: "/swarm/node-identity", Summary: "Get local swarm node identity", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNodeIdentity) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/tasks", Summary: "List swarm tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListTasks) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-stacks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks", Summary: "List swarm stacks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStacks) - huma.Register(api, huma.Operation{OperationID: "deploy-swarm-stack", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks", Summary: "Deploy swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeployStack) - huma.Register(api, huma.Operation{OperationID: "get-swarm-stack", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Get swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetStack) - huma.Register(api, huma.Operation{OperationID: "get-swarm-stack-source", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Get swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetStackSource) - huma.Register(api, huma.Operation{OperationID: "update-swarm-stack-source", Method: http.MethodPut, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Update swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateStackSource) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-stack", Method: http.MethodDelete, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Delete swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteStack) - huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/services", Summary: "List swarm stack services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStackServices) - huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/tasks", Summary: "List swarm stack tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStackTasks) - huma.Register(api, huma.Operation{OperationID: "render-swarm-stack-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks/config/render", Summary: "Render/validate swarm stack config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.RenderStackConfig) - - huma.Register(api, huma.Operation{OperationID: "get-swarm-status", Method: http.MethodGet, Path: "/environments/{id}/swarm/status", Summary: "Get swarm status", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSwarmStatus) - huma.Register(api, huma.Operation{OperationID: "get-swarm-info", Method: http.MethodGet, Path: "/environments/{id}/swarm/info", Summary: "Get swarm info", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSwarmInfo) - huma.Register(api, huma.Operation{OperationID: "init-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/init", Summary: "Initialize swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.InitSwarm) - huma.Register(api, huma.Operation{OperationID: "join-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/join", Summary: "Join swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.JoinSwarm) - huma.Register(api, huma.Operation{OperationID: "leave-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/leave", Summary: "Leave swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.LeaveSwarm) - huma.Register(api, huma.Operation{OperationID: "unlock-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/unlock", Summary: "Unlock swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UnlockSwarm) - huma.Register(api, huma.Operation{OperationID: "get-swarm-unlock-key", Method: http.MethodGet, Path: "/environments/{id}/swarm/unlock-key", Summary: "Get swarm unlock key", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetUnlockKey) - huma.Register(api, huma.Operation{OperationID: "get-swarm-join-tokens", Method: http.MethodGet, Path: "/environments/{id}/swarm/join-tokens", Summary: "Get swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetJoinTokens) - huma.Register(api, huma.Operation{OperationID: "rotate-swarm-join-tokens", Method: http.MethodPost, Path: "/environments/{id}/swarm/join-tokens/rotate", Summary: "Rotate swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.RotateJoinTokens) - huma.Register(api, huma.Operation{OperationID: "update-swarm-spec", Method: http.MethodPut, Path: "/environments/{id}/swarm/spec", Summary: "Update swarm spec", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateSwarmSpec) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-configs", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs", Summary: "List swarm configs", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListConfigs) - huma.Register(api, huma.Operation{OperationID: "get-swarm-config", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Get swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetConfig) - huma.Register(api, huma.Operation{OperationID: "create-swarm-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/configs", Summary: "Create swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateConfig) - huma.Register(api, huma.Operation{OperationID: "update-swarm-config", Method: http.MethodPut, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Update swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateConfig) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-config", Method: http.MethodDelete, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Delete swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteConfig) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-secrets", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets", Summary: "List swarm secrets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListSecrets) - huma.Register(api, huma.Operation{OperationID: "get-swarm-secret", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Get swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSecret) - huma.Register(api, huma.Operation{OperationID: "create-swarm-secret", Method: http.MethodPost, Path: "/environments/{id}/swarm/secrets", Summary: "Create swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateSecret) - huma.Register(api, huma.Operation{OperationID: "update-swarm-secret", Method: http.MethodPut, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Update swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateSecret) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-secret", Method: http.MethodDelete, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Delete swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteSecret) + huma.Register(api, huma.Operation{OperationID: "list-swarm-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/services", Summary: "List swarm services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListServices) + huma.Register(api, huma.Operation{OperationID: "get-swarm-service", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Get swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetService) + huma.Register(api, huma.Operation{OperationID: "create-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services", Summary: "Create swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.CreateService) + huma.Register(api, huma.Operation{OperationID: "update-swarm-service", Method: http.MethodPut, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Update swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.UpdateService) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-service", Method: http.MethodDelete, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Delete swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.DeleteService) + huma.Register(api, huma.Operation{OperationID: "list-swarm-service-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}/tasks", Summary: "List tasks for a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListServiceTasks) + huma.Register(api, huma.Operation{OperationID: "rollback-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/rollback", Summary: "Rollback a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.RollbackService) + huma.Register(api, huma.Operation{OperationID: "scale-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/scale", Summary: "Scale a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.ScaleService) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-nodes", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes", Summary: "List swarm nodes", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListNodes) + huma.Register(api, huma.Operation{OperationID: "get-swarm-node", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Get swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetNode) + huma.Register(api, huma.Operation{OperationID: "get-swarm-node-agent-deployment", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/agent/deployment", Summary: "Get swarm node agent deployment snippets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.GetNodeAgentDeployment) + huma.Register(api, huma.Operation{OperationID: "update-swarm-node", Method: http.MethodPatch, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Update swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.UpdateNode) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-node", Method: http.MethodDelete, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Delete swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.DeleteNode) + huma.Register(api, huma.Operation{OperationID: "promote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/promote", Summary: "Promote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.PromoteNode) + huma.Register(api, huma.Operation{OperationID: "demote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/demote", Summary: "Demote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.DemoteNode) + huma.Register(api, huma.Operation{OperationID: "list-swarm-node-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}/tasks", Summary: "List tasks for a swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListNodeTasks) + huma.Register(api, huma.Operation{OperationID: "get-swarm-node-identity", Method: http.MethodGet, Path: "/swarm/node-identity", Summary: "Get local swarm node identity", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetNodeIdentity) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/tasks", Summary: "List swarm tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListTasks) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-stacks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks", Summary: "List swarm stacks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListStacks) + huma.Register(api, huma.Operation{OperationID: "deploy-swarm-stack", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks", Summary: "Deploy swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.DeployStack) + huma.Register(api, huma.Operation{OperationID: "get-swarm-stack", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Get swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetStack) + huma.Register(api, huma.Operation{OperationID: "get-swarm-stack-source", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Get swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.GetStackSource) + huma.Register(api, huma.Operation{OperationID: "update-swarm-stack-source", Method: http.MethodPut, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Update swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.UpdateStackSource) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-stack", Method: http.MethodDelete, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Delete swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.DeleteStack) + huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/services", Summary: "List swarm stack services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListStackServices) + huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/tasks", Summary: "List swarm stack tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListStackTasks) + huma.Register(api, huma.Operation{OperationID: "render-swarm-stack-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks/config/render", Summary: "Render/validate swarm stack config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.RenderStackConfig) + + huma.Register(api, huma.Operation{OperationID: "get-swarm-status", Method: http.MethodGet, Path: "/environments/{id}/swarm/status", Summary: "Get swarm status", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetSwarmStatus) + huma.Register(api, huma.Operation{OperationID: "get-swarm-info", Method: http.MethodGet, Path: "/environments/{id}/swarm/info", Summary: "Get swarm info", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetSwarmInfo) + huma.Register(api, huma.Operation{OperationID: "init-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/init", Summary: "Initialize swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmInit)}, h.InitSwarm) + huma.Register(api, huma.Operation{OperationID: "join-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/join", Summary: "Join swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmJoin)}, h.JoinSwarm) + huma.Register(api, huma.Operation{OperationID: "leave-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/leave", Summary: "Leave swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmLeave)}, h.LeaveSwarm) + huma.Register(api, huma.Operation{OperationID: "unlock-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/unlock", Summary: "Unlock swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.UnlockSwarm) + huma.Register(api, huma.Operation{OperationID: "get-swarm-unlock-key", Method: http.MethodGet, Path: "/environments/{id}/swarm/unlock-key", Summary: "Get swarm unlock key", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.GetUnlockKey) + huma.Register(api, huma.Operation{OperationID: "get-swarm-join-tokens", Method: http.MethodGet, Path: "/environments/{id}/swarm/join-tokens", Summary: "Get swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.GetJoinTokens) + huma.Register(api, huma.Operation{OperationID: "rotate-swarm-join-tokens", Method: http.MethodPost, Path: "/environments/{id}/swarm/join-tokens/rotate", Summary: "Rotate swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.RotateJoinTokens) + huma.Register(api, huma.Operation{OperationID: "update-swarm-spec", Method: http.MethodPut, Path: "/environments/{id}/swarm/spec", Summary: "Update swarm spec", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSpec)}, h.UpdateSwarmSpec) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-configs", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs", Summary: "List swarm configs", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListConfigs) + huma.Register(api, huma.Operation{OperationID: "get-swarm-config", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Get swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetConfig) + huma.Register(api, huma.Operation{OperationID: "create-swarm-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/configs", Summary: "Create swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmConfigs)}, h.CreateConfig) + huma.Register(api, huma.Operation{OperationID: "update-swarm-config", Method: http.MethodPut, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Update swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmConfigs)}, h.UpdateConfig) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-config", Method: http.MethodDelete, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Delete swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmConfigs)}, h.DeleteConfig) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-secrets", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets", Summary: "List swarm secrets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListSecrets) + huma.Register(api, huma.Operation{OperationID: "get-swarm-secret", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Get swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetSecret) + huma.Register(api, huma.Operation{OperationID: "create-swarm-secret", Method: http.MethodPost, Path: "/environments/{id}/swarm/secrets", Summary: "Create swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSecrets)}, h.CreateSecret) + huma.Register(api, huma.Operation{OperationID: "update-swarm-secret", Method: http.MethodPut, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Update swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSecrets)}, h.UpdateSecret) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-secret", Method: http.MethodDelete, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Delete swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSecrets)}, h.DeleteSecret) } // ListServices lists swarm services for an environment and returns a paginated response. @@ -644,10 +645,6 @@ func (h *SwarmHandler) CreateService(ctx context.Context, input *CreateSwarmServ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.CreateService(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceCreateError{Err: err}).Error()) @@ -673,10 +670,6 @@ func (h *SwarmHandler) UpdateService(ctx context.Context, input *UpdateSwarmServ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.UpdateService(ctx, input.ServiceID, input.Body) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceUpdateError{Err: err}).Error()) @@ -703,10 +696,6 @@ func (h *SwarmHandler) DeleteService(ctx context.Context, input *DeleteSwarmServ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveService(ctx, input.ServiceID); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound((&common.SwarmServiceNotFoundError{Err: err}).Error()) @@ -761,10 +750,6 @@ func (h *SwarmHandler) RollbackService(ctx context.Context, input *RollbackSwarm if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.RollbackService(ctx, input.ServiceID) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceUpdateError{Err: err}).Error()) @@ -790,10 +775,6 @@ func (h *SwarmHandler) ScaleService(ctx context.Context, input *ScaleSwarmServic if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.ScaleService(ctx, input.ServiceID, input.Body.Replicas) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceUpdateError{Err: err}).Error()) @@ -877,10 +858,6 @@ func (h *SwarmHandler) GetNodeAgentDeployment(ctx context.Context, input *GetSwa if h.swarmService == nil || h.environmentService == nil || h.cfg == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - node, err := h.swarmService.GetNode(ctx, input.EnvironmentID, input.NodeID) if err != nil { if errdefs.IsNotFound(err) { @@ -981,10 +958,6 @@ func (h *SwarmHandler) UpdateNode(ctx context.Context, input *UpdateSwarmNodeInp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateNode(ctx, input.NodeID, input.Body); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1009,10 +982,6 @@ func (h *SwarmHandler) DeleteNode(ctx context.Context, input *DeleteSwarmNodeInp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveNode(ctx, input.NodeID, input.Force); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1037,10 +1006,6 @@ func (h *SwarmHandler) PromoteNode(ctx context.Context, input *PromoteSwarmNodeI if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.PromoteNode(ctx, input.NodeID); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1065,10 +1030,6 @@ func (h *SwarmHandler) DemoteNode(ctx context.Context, input *DemoteSwarmNodeInp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.DemoteNode(ctx, input.NodeID); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1175,10 +1136,6 @@ func (h *SwarmHandler) DeployStack(ctx context.Context, input *DeploySwarmStackI if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.DeployStack(ctx, input.EnvironmentID, input.Body) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmStackDeployError{Err: err}).Error()) @@ -1231,10 +1188,6 @@ func (h *SwarmHandler) GetStackSource(ctx context.Context, input *GetSwarmStackS if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - source, err := h.swarmService.GetStackSource(ctx, input.EnvironmentID, input.Name) if err != nil { if errdefs.IsNotFound(err) { @@ -1255,10 +1208,6 @@ func (h *SwarmHandler) UpdateStackSource(ctx context.Context, input *UpdateSwarm if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - source, err := h.swarmService.UpdateStackSource(ctx, input.EnvironmentID, input.Name, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm stack source") @@ -1285,10 +1234,6 @@ func (h *SwarmHandler) DeleteStack(ctx context.Context, input *DeleteSwarmStackI if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveStack(ctx, input.EnvironmentID, input.Name); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound("Swarm stack not found") @@ -1452,10 +1397,6 @@ func (h *SwarmHandler) InitSwarm(ctx context.Context, input *InitSwarmInput) (*I if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.InitSwarm(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to initialize swarm") @@ -1481,10 +1422,6 @@ func (h *SwarmHandler) JoinSwarm(ctx context.Context, input *JoinSwarmInput) (*J if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.JoinSwarm(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to join swarm") } @@ -1509,10 +1446,6 @@ func (h *SwarmHandler) LeaveSwarm(ctx context.Context, input *LeaveSwarmInput) ( if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.LeaveSwarm(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to leave swarm") } @@ -1537,10 +1470,6 @@ func (h *SwarmHandler) UnlockSwarm(ctx context.Context, input *UnlockSwarmInput) if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UnlockSwarm(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to unlock swarm") } @@ -1564,10 +1493,6 @@ func (h *SwarmHandler) GetUnlockKey(ctx context.Context, input *GetSwarmUnlockKe if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.GetSwarmUnlockKey(ctx) if err != nil { return nil, mapSwarmServiceError(err, "Failed to get swarm unlock key") @@ -1590,10 +1515,6 @@ func (h *SwarmHandler) GetJoinTokens(ctx context.Context, input *GetSwarmJoinTok if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.GetSwarmJoinTokens(ctx) if err != nil { return nil, mapSwarmServiceError(err, "Failed to get swarm join tokens") @@ -1617,10 +1538,6 @@ func (h *SwarmHandler) RotateJoinTokens(ctx context.Context, input *RotateSwarmJ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RotateSwarmJoinTokens(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to rotate swarm join tokens") } @@ -1645,10 +1562,6 @@ func (h *SwarmHandler) UpdateSwarmSpec(ctx context.Context, input *UpdateSwarmSp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateSwarmSpec(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm spec") } @@ -1726,10 +1639,6 @@ func (h *SwarmHandler) CreateConfig(ctx context.Context, input *CreateSwarmConfi if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - cfg, err := h.swarmService.CreateConfig(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to create swarm config") @@ -1756,10 +1665,6 @@ func (h *SwarmHandler) UpdateConfig(ctx context.Context, input *UpdateSwarmConfi if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateConfig(ctx, input.ConfigID, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm config") } @@ -1781,10 +1686,6 @@ func (h *SwarmHandler) DeleteConfig(ctx context.Context, input *DeleteSwarmConfi if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveConfig(ctx, input.ConfigID); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound("Swarm config not found") @@ -1865,10 +1766,6 @@ func (h *SwarmHandler) CreateSecret(ctx context.Context, input *CreateSwarmSecre if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - secret, err := h.swarmService.CreateSecret(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to create swarm secret") @@ -1895,10 +1792,6 @@ func (h *SwarmHandler) UpdateSecret(ctx context.Context, input *UpdateSwarmSecre if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateSecret(ctx, input.SecretID, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm secret") } @@ -1920,10 +1813,6 @@ func (h *SwarmHandler) DeleteSecret(ctx context.Context, input *DeleteSwarmSecre if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveSecret(ctx, input.SecretID); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound("Swarm secret not found") diff --git a/backend/api/handlers/system.go b/backend/api/handlers/system.go index 90bac028ff..eda95b4ecb 100644 --- a/backend/api/handlers/system.go +++ b/backend/api/handlers/system.go @@ -11,6 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" @@ -146,6 +147,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermSystemRead), }, h.GetDockerInfo) huma.Register(api, huma.Operation{ @@ -159,7 +161,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSystemPrune), }, h.PruneAll) huma.Register(api, huma.Operation{ @@ -173,7 +175,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStart), }, h.StartAllContainers) huma.Register(api, huma.Operation{ @@ -187,7 +189,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStart), }, h.StartAllStoppedContainers) huma.Register(api, huma.Operation{ @@ -201,7 +203,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStop), }, h.StopAllContainers) huma.Register(api, huma.Operation{ @@ -215,6 +217,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermContainersCreate), }, h.ConvertDockerRun) huma.Register(api, huma.Operation{ @@ -228,7 +231,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSystemRead), }, h.CheckUpgradeAvailable) huma.Register(api, huma.Operation{ @@ -243,7 +246,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSystemUpgrade), }, h.TriggerUpgrade) } @@ -364,10 +367,6 @@ func (h *SystemHandler) PruneAll(ctx context.Context, input *PruneAllInput) (*Pr return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - slog.InfoContext(ctx, "System prune operation initiated", "containers", input.Body.Containers, "images", input.Body.Images, @@ -402,10 +401,6 @@ func (h *SystemHandler) StartAllContainers(ctx context.Context, input *StartAllC return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - result, err := h.systemService.StartAllContainers(ctx) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartAllError{Err: err}).Error()) @@ -425,10 +420,6 @@ func (h *SystemHandler) StartAllStoppedContainers(ctx context.Context, input *St return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - result, err := h.systemService.StartAllStoppedContainers(ctx) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartStoppedError{Err: err}).Error()) @@ -448,10 +439,6 @@ func (h *SystemHandler) StopAllContainers(ctx context.Context, input *StopAllCon return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - result, err := h.systemService.StopAllContainers(ctx) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStopAllError{Err: err}).Error()) @@ -497,10 +484,6 @@ func (h *SystemHandler) CheckUpgradeAvailable(ctx context.Context, input *CheckU return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - canUpgrade, err := h.upgradeService.CanUpgrade(ctx) if err != nil { slog.Debug("System upgrade check failed", "error", err) @@ -528,10 +511,6 @@ func (h *SystemHandler) TriggerUpgrade(ctx context.Context, input *TriggerUpgrad return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) diff --git a/backend/api/handlers/templates.go b/backend/api/handlers/templates.go index dea6c411a7..3122f8b421 100644 --- a/backend/api/handlers/templates.go +++ b/backend/api/handlers/templates.go @@ -11,6 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/env" @@ -192,7 +193,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.FetchRegistry) huma.Register(api, huma.Operation{ @@ -202,6 +203,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "List templates (paginated)", Description: "Get a paginated list of compose templates", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesList), }, h.ListTemplates) huma.Register(api, huma.Operation{ @@ -211,6 +213,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "List all templates", Description: "Get all compose templates without pagination", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesList), }, h.GetAllTemplates) huma.Register(api, huma.Operation{ @@ -220,6 +223,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "Get a template", Description: "Get a compose template by ID", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetTemplate) huma.Register(api, huma.Operation{ @@ -229,6 +233,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "Get template content", Description: "Get the compose content for a template with parsed data", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetTemplateContent) // Protected endpoints @@ -243,6 +248,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesCreate), }, h.CreateTemplate) huma.Register(api, huma.Operation{ @@ -256,6 +262,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.UpdateTemplate) huma.Register(api, huma.Operation{ @@ -269,6 +276,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesDelete), }, h.DeleteTemplate) huma.Register(api, huma.Operation{ @@ -282,6 +290,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.DownloadTemplate) huma.Register(api, huma.Operation{ @@ -295,6 +304,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetDefaultTemplates) huma.Register(api, huma.Operation{ @@ -308,6 +318,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.SaveDefaultTemplates) huma.Register(api, huma.Operation{ @@ -321,6 +332,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesList), }, h.GetRegistries) huma.Register(api, huma.Operation{ @@ -334,7 +346,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesCreate), }, h.CreateRegistry) huma.Register(api, huma.Operation{ @@ -348,7 +360,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.UpdateRegistry) huma.Register(api, huma.Operation{ @@ -362,7 +374,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesDelete), }, h.DeleteRegistry) huma.Register(api, huma.Operation{ @@ -376,7 +388,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetGlobalVariables) huma.Register(api, huma.Operation{ @@ -390,7 +402,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.UpdateGlobalVariables) } @@ -771,9 +783,6 @@ func (h *TemplateHandler) GetRegistries(ctx context.Context, _ *GetTemplateRegis // CreateRegistry creates a new template registry. func (h *TemplateHandler) CreateRegistry(ctx context.Context, input *CreateTemplateRegistryInput) (*CreateTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -803,9 +812,6 @@ func (h *TemplateHandler) CreateRegistry(ctx context.Context, input *CreateTempl // UpdateRegistry updates a template registry. func (h *TemplateHandler) UpdateRegistry(ctx context.Context, input *UpdateTemplateRegistryInput) (*UpdateTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -839,9 +845,6 @@ func (h *TemplateHandler) UpdateRegistry(ctx context.Context, input *UpdateTempl // DeleteRegistry deletes a template registry. func (h *TemplateHandler) DeleteRegistry(ctx context.Context, input *DeleteTemplateRegistryInput) (*DeleteTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -869,9 +872,6 @@ func (h *TemplateHandler) DeleteRegistry(ctx context.Context, input *DeleteTempl // FetchRegistry fetches templates from a remote registry URL. func (h *TemplateHandler) FetchRegistry(ctx context.Context, input *FetchTemplateRegistryInput) (*FetchTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -900,9 +900,6 @@ func (h *TemplateHandler) FetchRegistry(ctx context.Context, input *FetchTemplat // GetGlobalVariables returns global template variables. func (h *TemplateHandler) GetGlobalVariables(ctx context.Context, input *GetGlobalVariablesInput) (*GetGlobalVariablesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -939,9 +936,6 @@ func (h *TemplateHandler) getGlobalVariablesForRemoteEnvironmentInternal(ctx con // UpdateGlobalVariables updates global template variables. func (h *TemplateHandler) UpdateGlobalVariables(ctx context.Context, input *UpdateGlobalVariablesInput) (*UpdateGlobalVariablesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/templates_test.go b/backend/api/handlers/templates_test.go index 274dee874b..225a5eb9af 100644 --- a/backend/api/handlers/templates_test.go +++ b/backend/api/handlers/templates_test.go @@ -17,6 +17,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" glsqlite "github.com/glebarez/sqlite" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" @@ -130,7 +131,6 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *echo.Ech _, err = userService.CreateUser(context.Background(), &models.User{ BaseModel: models.BaseModel{ID: "user-1"}, Username: "alice", - Roles: models.StringSlice{"admin"}, }) require.NoError(t, err) require.NoError(t, db.Create(&models.UserSession{ @@ -141,7 +141,7 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *echo.Ech ExpiresAt: time.Now().Add(time.Hour), }).Error) - authService := services.NewAuthService(userService, nil, nil, services.NewSessionService(databaseDB), "test-secret", &config.Config{}) + authService := services.NewAuthService(userService, nil, nil, services.NewSessionService(databaseDB), nil, "test-secret", &config.Config{}) templateService := services.NewTemplateService(context.Background(), nil, httpClient, nil) router := echo.New() @@ -165,13 +165,25 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *echo.Ech } api := humaecho.NewWithGroup(router, apiGroup, humaConfig) - api.UseMiddleware(humamiddleware.NewAuthBridge(api, authService, nil, nil, &config.Config{})) + api.UseMiddleware(humamiddleware.NewAuthBridge(api, authService, nil, sudoPermResolver{}, nil, &config.Config{})) RegisterHealth(api) RegisterTemplates(api, templateService, nil) return router } +// sudoPermResolver is a test stub satisfying humamiddleware.PermissionResolver +// that grants every permission. Used in tests that don't care about RBAC +// gating — they want auth to succeed and permissions to be unrestricted. +type sudoPermResolver struct{} + +func (sudoPermResolver) ResolvePermissions(_ context.Context, _ *models.User) (*authz.PermissionSet, error) { + return authz.SudoPermissionSet(), nil +} +func (sudoPermResolver) ResolveApiKeyPermissions(_ context.Context, _ string) (*authz.PermissionSet, error) { + return authz.SudoPermissionSet(), nil +} + func makeTemplateFetchToken(t *testing.T, secret string, userID, username string) string { t.Helper() diff --git a/backend/api/handlers/updater.go b/backend/api/handlers/updater.go index a6ea764d2e..c168bf2f7c 100644 --- a/backend/api/handlers/updater.go +++ b/backend/api/handlers/updater.go @@ -9,6 +9,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/updater" ) @@ -72,7 +73,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.RunUpdater) huma.Register(api, huma.Operation{ @@ -86,6 +87,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdaterStatus) huma.Register(api, huma.Operation{ @@ -99,6 +101,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdaterHistory) huma.Register(api, huma.Operation{ @@ -112,15 +115,12 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.UpdateContainer) } // RunUpdater applies pending container updates. func (h *UpdaterHandler) RunUpdater(ctx context.Context, input *RunUpdaterInput) (*RunUpdaterOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.updaterService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -185,9 +185,6 @@ func (h *UpdaterHandler) GetUpdaterHistory(ctx context.Context, input *GetUpdate // UpdateContainer updates a single container by pulling the latest image and applying the appropriate update flow. func (h *UpdaterHandler) UpdateContainer(ctx context.Context, input *UpdateContainerInput) (*UpdateContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.updaterService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/users.go b/backend/api/handlers/users.go index ac5c3e5821..bbf9341451 100644 --- a/backend/api/handlers/users.go +++ b/backend/api/handlers/users.go @@ -11,6 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/utils/validation" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/user" @@ -97,7 +98,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersList), }, h.ListUsers) huma.Register(api, huma.Operation{ @@ -111,7 +112,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersCreate), }, h.CreateUser) huma.Register(api, huma.Operation{ @@ -125,7 +126,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersRead), }, h.GetUser) huma.Register(api, huma.Operation{ @@ -139,7 +140,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersUpdate), }, h.UpdateUser) huma.Register(api, huma.Operation{ @@ -153,7 +154,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersDelete), }, h.DeleteUser) } @@ -167,10 +168,6 @@ func (h *UserHandler) ListUsers(ctx context.Context, input *ListUsersInput) (*Li return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) users, paginationResp, err := h.userService.ListUsersPaginated(ctx, params) @@ -199,10 +196,6 @@ func (h *UserHandler) CreateUser(ctx context.Context, input *CreateUserInput) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - normalizedEmail, err := normalizeOptionalEmailInternal(input.Body.Email) if err != nil { return nil, huma.Error400BadRequest(err.Error()) @@ -219,17 +212,12 @@ func (h *UserHandler) CreateUser(ctx context.Context, input *CreateUserInput) (* PasswordHash: hashedPassword, DisplayName: input.Body.DisplayName, Email: input.Body.Email, - Roles: input.Body.Roles, Locale: input.Body.Locale, BaseModel: models.BaseModel{ CreatedAt: time.Now(), }, } - if userModel.Roles == nil { - userModel.Roles = []string{"user"} - } - createdUser, err := h.userService.CreateUser(ctx, userModel) if err != nil { return nil, huma.Error500InternalServerError((&common.UserCreationError{Err: err}).Error()) @@ -254,10 +242,6 @@ func (h *UserHandler) GetUser(ctx context.Context, input *GetUserInput) (*GetUse return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - userModel, err := h.userService.GetUserByID(ctx, input.UserID) if err != nil { return nil, huma.Error404NotFound((&common.UserNotFoundError{}).Error()) @@ -282,10 +266,6 @@ func (h *UserHandler) UpdateUser(ctx context.Context, input *UpdateUserInput) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - userModel, err := h.userService.GetUserByID(ctx, input.UserID) if err != nil { return nil, huma.Error404NotFound((&common.UserNotFoundError{}).Error()) @@ -306,9 +286,6 @@ func (h *UserHandler) UpdateUser(ctx context.Context, input *UpdateUserInput) (* if input.Body.Email != nil { userModel.Email = input.Body.Email } - if input.Body.Roles != nil { - userModel.Roles = input.Body.Roles - } if input.Body.Locale != nil { userModel.Locale = input.Body.Locale } @@ -357,10 +334,6 @@ func (h *UserHandler) DeleteUser(ctx context.Context, input *DeleteUserInput) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.userService.DeleteUser(ctx, input.UserID); err != nil { if errors.Is(err, services.ErrCannotRemoveLastAdmin) { return nil, huma.Error409Conflict(services.ErrCannotRemoveLastAdmin.Error()) diff --git a/backend/api/handlers/users_test.go b/backend/api/handlers/users_test.go index 732864a9f4..9913be812e 100644 --- a/backend/api/handlers/users_test.go +++ b/backend/api/handlers/users_test.go @@ -9,7 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" - usertypes "github.com/getarcaneapp/arcane/types/user" + "github.com/getarcaneapp/arcane/backend/pkg/authz" glsqlite "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -27,13 +27,12 @@ func setupUserHandlerTestDB(t *testing.T) *database.DB { return &database.DB{DB: db} } -func createHandlerTestUser(t *testing.T, svc *services.UserService, id, username string, roles models.StringSlice) *models.User { +func createHandlerTestUser(t *testing.T, svc *services.UserService, id, username string, _ models.StringSlice) *models.User { t.Helper() user := &models.User{ BaseModel: models.BaseModel{ID: id}, Username: username, - Roles: roles, } created, err := svc.CreateUser(context.Background(), user) @@ -43,14 +42,20 @@ func createHandlerTestUser(t *testing.T, svc *services.UserService, id, username } func adminContext() context.Context { - return context.WithValue(context.Background(), humamw.ContextKeyUserIsAdmin, true) + return context.WithValue(context.Background(), humamw.ContextKeyUserPermissions, authz.SudoPermissionSet()) } func TestDeleteUserReturnsConflictForLastAdmin(t *testing.T) { db := setupUserHandlerTestDB(t) - userSvc := services.NewUserService(db) + require.NoError(t, db.AutoMigrate(&models.Role{}, &models.UserRoleAssignment{}, &models.Environment{})) + roleSvc := services.NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(context.Background())) + userSvc := services.NewUserService(db).WithRoleService(roleSvc) handler := &UserHandler{userService: userSvc} - admin := createHandlerTestUser(t, userSvc, "admin-1", "arcane", models.StringSlice{"admin"}) + admin := createHandlerTestUser(t, userSvc, "admin-1", "arcane", models.StringSlice{}) + require.NoError(t, roleSvc.SetUserAssignments(context.Background(), admin.ID, []models.UserRoleAssignment{ + {RoleID: authz.BuiltInRoleAdmin, EnvironmentID: nil}, + })) _, err := handler.DeleteUser(adminContext(), &DeleteUserInput{UserID: admin.ID}) require.Error(t, err) @@ -60,23 +65,3 @@ func TestDeleteUserReturnsConflictForLastAdmin(t *testing.T) { require.Equal(t, http.StatusConflict, statusErr.GetStatus()) require.Contains(t, statusErr.Error(), services.ErrCannotRemoveLastAdmin.Error()) } - -func TestUpdateUserReturnsConflictWhenRemovingLastAdminRole(t *testing.T) { - db := setupUserHandlerTestDB(t) - userSvc := services.NewUserService(db) - handler := &UserHandler{userService: userSvc} - admin := createHandlerTestUser(t, userSvc, "admin-1", "arcane", models.StringSlice{"ADMIN"}) - - _, err := handler.UpdateUser(adminContext(), &UpdateUserInput{ - UserID: admin.ID, - Body: usertypes.UpdateUser{ - Roles: []string{"user"}, - }, - }) - require.Error(t, err) - - var statusErr huma.StatusError - require.ErrorAs(t, err, &statusErr) - require.Equal(t, http.StatusConflict, statusErr.GetStatus()) - require.Contains(t, statusErr.Error(), services.ErrCannotRemoveLastAdmin.Error()) -} diff --git a/backend/api/handlers/volumes.go b/backend/api/handlers/volumes.go index ed7aa05c1e..6989d1748c 100644 --- a/backend/api/handlers/volumes.go +++ b/backend/api/handlers/volumes.go @@ -13,6 +13,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" volumetypes "github.com/getarcaneapp/arcane/types/volume" @@ -311,6 +312,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesList), }, h.GetVolumeUsageCounts) huma.Register(api, huma.Operation{ @@ -324,6 +326,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesList), }, h.ListVolumes) huma.Register(api, huma.Operation{ @@ -337,6 +340,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesRead), }, h.GetVolume) huma.Register(api, huma.Operation{ @@ -350,7 +354,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesCreate), }, h.CreateVolume) huma.Register(api, huma.Operation{ @@ -364,7 +368,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesDelete), }, h.RemoveVolume) huma.Register(api, huma.Operation{ @@ -378,7 +382,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesPrune), }, h.PruneVolumes) huma.Register(api, huma.Operation{ @@ -392,6 +396,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesRead), }, h.GetVolumeUsage) huma.Register(api, huma.Operation{ @@ -405,6 +410,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesList), }, h.GetVolumeSizes) // --- Volume Browsing Endpoints --- @@ -419,6 +425,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.BrowseDirectory) huma.Register(api, huma.Operation{ @@ -431,6 +438,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.GetFileContent) huma.Register(api, huma.Operation{ @@ -443,6 +451,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.DownloadFile) huma.Register(api, huma.Operation{ @@ -472,7 +481,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesUpload), }, h.UploadFile) huma.Register(api, huma.Operation{ @@ -485,7 +494,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesUpload), }, h.CreateDirectory) huma.Register(api, huma.Operation{ @@ -498,7 +507,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesDelete), }, h.DeleteFile) // --- Volume Backup Endpoints --- @@ -513,6 +522,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.ListBackups) huma.Register(api, huma.Operation{ @@ -525,7 +535,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.CreateBackup) huma.Register(api, huma.Operation{ @@ -538,7 +548,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.RestoreBackup) huma.Register(api, huma.Operation{ @@ -551,7 +561,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.RestoreBackupFiles) huma.Register(api, huma.Operation{ @@ -564,7 +574,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.DeleteBackup) huma.Register(api, huma.Operation{ @@ -577,6 +587,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.DownloadBackup) huma.Register(api, huma.Operation{ @@ -589,6 +600,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.BackupHasPath) huma.Register(api, huma.Operation{ @@ -601,6 +613,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.ListBackupFiles) huma.Register(api, huma.Operation{ @@ -630,7 +643,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesUpload), }, h.UploadAndRestore) } @@ -714,9 +727,6 @@ func (h *VolumeHandler) GetVolume(ctx context.Context, input *GetVolumeInput) (* // CreateVolume creates a new Docker volume. func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInput) (*CreateVolumeOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -748,9 +758,6 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp // RemoveVolume removes a Docker volume. func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInput) (*RemoveVolumeOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -776,9 +783,6 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp // PruneVolumes removes all unused Docker volumes. func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInput) (*PruneVolumesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -934,9 +938,6 @@ func (h *VolumeHandler) DownloadFile(ctx context.Context, input *DownloadFileInp } func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -964,9 +965,6 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) } func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -982,9 +980,6 @@ func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirect } func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1053,9 +1048,6 @@ func (h *VolumeHandler) ListBackups(ctx context.Context, input *ListBackupsInput } func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInput) (*CreateBackupOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1077,9 +1069,6 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp } func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupInput) (*RestoreBackupOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1101,9 +1090,6 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI } func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBackupFilesInput) (*RestoreBackupFilesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1170,9 +1156,6 @@ func (h *VolumeHandler) ListBackupFiles(ctx context.Context, input *ListBackupFi } func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInput) (*DeleteBackupOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1214,9 +1197,6 @@ func (h *VolumeHandler) DownloadBackup(ctx context.Context, input *DownloadBacku } func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRestoreInput) (*UploadAndRestoreOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/vulnerabilities.go b/backend/api/handlers/vulnerabilities.go index 3d0683c6a7..e555bf8277 100644 --- a/backend/api/handlers/vulnerabilities.go +++ b/backend/api/handlers/vulnerabilities.go @@ -8,6 +8,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/vulnerability" ) @@ -137,7 +138,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVulnsScan), }, h.ScanImage) huma.Register(api, huma.Operation{ @@ -151,6 +152,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScanResult) huma.Register(api, huma.Operation{ @@ -164,6 +166,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScanSummary) huma.Register(api, huma.Operation{ @@ -177,6 +180,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScanSummaries) huma.Register(api, huma.Operation{ @@ -190,6 +194,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListImageVulnerabilities) huma.Register(api, huma.Operation{ @@ -203,6 +208,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScannerStatus) huma.Register(api, huma.Operation{ @@ -216,6 +222,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetEnvironmentSummary) huma.Register(api, huma.Operation{ @@ -229,6 +236,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListAllVulnerabilities) huma.Register(api, huma.Operation{ @@ -242,6 +250,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListAllVulnerabilityImageOptions) huma.Register(api, huma.Operation{ @@ -255,7 +264,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVulnsManage), }, h.IgnoreVulnerability) huma.Register(api, huma.Operation{ @@ -269,7 +278,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVulnsManage), }, h.UnignoreVulnerability) huma.Register(api, huma.Operation{ @@ -283,14 +292,12 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListIgnoredVulnerabilities) } // ScanImage initiates a vulnerability scan for an image. func (h *VulnerabilityHandler) ScanImage(ctx context.Context, input *ScanImageInput) (*ScanImageOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.vulnerabilityService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -548,9 +555,6 @@ type IgnoreVulnerabilityOutput struct { // IgnoreVulnerability creates an ignore record for a vulnerability. func (h *VulnerabilityHandler) IgnoreVulnerability(ctx context.Context, input *IgnoreVulnerabilityInput) (*IgnoreVulnerabilityOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.vulnerabilityService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -600,9 +604,6 @@ type UnignoreVulnerabilityOutput struct { // UnignoreVulnerability removes an ignore record for a vulnerability. func (h *VulnerabilityHandler) UnignoreVulnerability(ctx context.Context, input *UnignoreVulnerabilityInput) (*UnignoreVulnerabilityOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.vulnerabilityService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/webhooks.go b/backend/api/handlers/webhooks.go index 60d36860eb..f130bb4204 100644 --- a/backend/api/handlers/webhooks.go +++ b/backend/api/handlers/webhooks.go @@ -9,6 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/webhook" ) @@ -70,6 +71,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksList), }, h.ListWebhooks) huma.Register(api, huma.Operation{ @@ -83,7 +85,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksCreate), }, h.CreateWebhook) huma.Register(api, huma.Operation{ @@ -97,7 +99,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksUpdate), }, h.UpdateWebhook) huma.Register(api, huma.Operation{ @@ -111,7 +113,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksDelete), }, h.DeleteWebhook) } @@ -136,9 +138,6 @@ func (h *WebhookHandler) ListWebhooks(ctx context.Context, input *ListWebhooksIn // CreateWebhook creates a new webhook and returns the raw token (shown once only). func (h *WebhookHandler) CreateWebhook(ctx context.Context, input *CreateWebhookInput) (*CreateWebhookOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.webhookService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -191,9 +190,6 @@ func (h *WebhookHandler) CreateWebhook(ctx context.Context, input *CreateWebhook // UpdateWebhook updates a webhook's enabled state. func (h *WebhookHandler) UpdateWebhook(ctx context.Context, input *UpdateWebhookInput) (*UpdateWebhookOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.webhookService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -222,9 +218,6 @@ func (h *WebhookHandler) UpdateWebhook(ctx context.Context, input *UpdateWebhook // DeleteWebhook removes a webhook. func (h *WebhookHandler) DeleteWebhook(ctx context.Context, input *DeleteWebhookInput) (*DeleteWebhookOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.webhookService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/middleware/auth.go b/backend/api/middleware/auth.go index 0e8fb817f7..ae3fb6b0cf 100644 --- a/backend/api/middleware/auth.go +++ b/backend/api/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "context" "errors" + "log/slog" "net/http" "strings" @@ -11,6 +12,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" ) @@ -25,8 +27,9 @@ const ( ContextKeyCurrentUser ContextKey = "currentUser" // ContextKeyCurrentSessionID is the context key for the authenticated session ID. ContextKeyCurrentSessionID ContextKey = "currentSessionID" - // ContextKeyUserIsAdmin is the context key for whether the user is an admin. - ContextKeyUserIsAdmin ContextKey = "userIsAdmin" + // ContextKeyUserPermissions is the context key for the caller's resolved + // PermissionSet, attached by the auth bridge. + ContextKeyUserPermissions ContextKey = "userPermissions" // ContextKeyRemoteAddr is the context key for the request remote address. ContextKeyRemoteAddr ContextKey = "remoteAddr" ) @@ -49,10 +52,11 @@ func GetCurrentSessionIDFromContext(ctx context.Context) (string, bool) { return sessionID, ok } -// IsAdminFromContext checks if the current user is an admin. -func IsAdminFromContext(ctx context.Context) bool { - isAdmin, ok := ctx.Value(ContextKeyUserIsAdmin).(bool) - return ok && isAdmin +// PermissionsFromContext retrieves the caller's resolved PermissionSet. +// Returns nil, false on unauthenticated paths. +func PermissionsFromContext(ctx context.Context) (*authz.PermissionSet, bool) { + ps, ok := ctx.Value(ContextKeyUserPermissions).(*authz.PermissionSet) + return ps, ok } // GetRemoteAddrFromContext retrieves the request remote address from context. @@ -76,6 +80,13 @@ type environmentAccessTokenResolver interface { ResolveEnvironmentByAccessToken(ctx context.Context, token string) (*models.Environment, error) } +// PermissionResolver resolves a caller's effective permission set. Implemented +// by services.RoleService; kept as an interface so tests can stub it. +type PermissionResolver interface { + ResolvePermissions(ctx context.Context, user *models.User) (*authz.PermissionSet, error) + ResolveApiKeyPermissions(ctx context.Context, apiKeyID string) (*authz.PermissionSet, error) +} + // parseSecurityRequirementsInternal extracts security requirements from a Huma operation. func parseSecurityRequirementsInternal(api huma.API, ctx operationProvider) securityRequirements { reqs := securityRequirements{} @@ -126,19 +137,21 @@ func tryBearerAuthInternal(ctx huma.Context, authService *services.AuthService) return user, sessionID, nil } -// tryApiKeyAuthInternal checks if API key authentication should be allowed through. -func tryApiKeyAuthInternal(ctx huma.Context, apiKeyService *services.ApiKeyService) (*models.User, bool) { +// tryApiKeyAuthInternal checks if API key authentication should be allowed +// through. Returns the resolved user plus the API key's database ID so the +// caller can fetch the key's own permission set. +func tryApiKeyAuthInternal(ctx huma.Context, apiKeyService *services.ApiKeyService) (*models.User, string, bool) { apiKey := ctx.Header(pkgutils.HeaderApiKey) if apiKey == "" { - return nil, false + return nil, "", false } - user, err := apiKeyService.ValidateApiKey(ctx.Context(), apiKey) + user, keyID, err := apiKeyService.ValidateApiKeyWithID(ctx.Context(), apiKey) if err != nil || user == nil { - return nil, false + return nil, "", false } - return user, true + return user, keyID, true } func tryEnvironmentAccessTokenAuthInternal(ctx huma.Context, resolver environmentAccessTokenResolver, token string) (*models.User, bool) { @@ -184,12 +197,13 @@ func tryAgentAuthInternal(ctx huma.Context, cfg *config.Config) (*models.User, b } // createAgentSudoUserInternal creates a sudo user for agent authentication. +// The PermissionSet attached to the context (via setUserInContextWithSudoInternal) +// bypasses every check; the user's Roles field is intentionally empty. func createAgentSudoUserInternal() *models.User { return &models.User{ BaseModel: models.BaseModel{ID: "agent"}, Email: new("agent@getarcane.app"), Username: "agent", - Roles: []string{"admin"}, } } @@ -197,13 +211,14 @@ func createEnvironmentSudoUserInternal(env *models.Environment) *models.User { return &models.User{ BaseModel: models.BaseModel{ID: "environment:" + env.ID}, Username: env.Name, - Roles: []string{"admin"}, } } -// NewAuthBridge creates a Huma middleware that validates JWT tokens and -// enforces security requirements defined on operations. -func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyService *services.ApiKeyService, envTokenResolver environmentAccessTokenResolver, cfg *config.Config) func(ctx huma.Context, next func(huma.Context)) { +// NewAuthBridge creates a Huma middleware that validates credentials and +// enforces security requirements defined on operations. It also resolves the +// caller's effective PermissionSet via permResolver and stashes it on the +// request context for downstream RequirePermission checks. +func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyService *services.ApiKeyService, permResolver PermissionResolver, envTokenResolver environmentAccessTokenResolver, cfg *config.Config) func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { ctx = huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyRemoteAddr, ctx.RemoteAddr())) if authService == nil { @@ -218,23 +233,23 @@ func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyServic reqs := parseSecurityRequirementsInternal(api, ctx) if !reqs.isRequired { - next(opportunisticBearerAuthInternal(ctx, authService)) + next(opportunisticBearerAuthInternal(ctx, authService, permResolver)) return } if reqs.apiKeyAuth && ctx.Header(pkgutils.HeaderApiKey) != "" { - handleApiKeyAuthInternal(api, ctx, apiKeyService, envTokenResolver, next) + handleApiKeyAuthInternal(api, ctx, apiKeyService, permResolver, envTokenResolver, next) return } if user, ok := tryEnvironmentAccessTokenAuthInternal(ctx, envTokenResolver, ctx.Header(pkgutils.HeaderAgentToken)); ok { - newCtx := setUserInContextInternal(ctx.Context(), user) + newCtx := setUserInContextWithSudoInternal(ctx.Context(), user) next(huma.WithContext(ctx, newCtx)) return } if reqs.bearerAuth { - nextCtx, handled := handleBearerAuthInternal(api, ctx, authService) + nextCtx, handled := handleBearerAuthInternal(api, ctx, authService, permResolver) if handled { if nextCtx != nil { next(nextCtx) @@ -255,13 +270,13 @@ func tryAgentAuthCtxInternal(ctx huma.Context, cfg *config.Config) (huma.Context if !ok { return ctx, false } - return huma.WithContext(ctx, setUserInContextInternal(ctx.Context(), user)), true + return huma.WithContext(ctx, setUserInContextWithSudoInternal(ctx.Context(), user)), true } // opportunisticBearerAuthInternal populates the user/session context if a valid // bearer token is present, but never fails the request. Used for public routes // (e.g. logout) that still need to know who the caller is when a token exists. -func opportunisticBearerAuthInternal(ctx huma.Context, authService *services.AuthService) huma.Context { +func opportunisticBearerAuthInternal(ctx huma.Context, authService *services.AuthService, permResolver PermissionResolver) huma.Context { if extractBearerTokenInternal(ctx) == "" { return ctx } @@ -269,31 +284,33 @@ func opportunisticBearerAuthInternal(ctx huma.Context, authService *services.Aut if err != nil || user == nil { return ctx } - newCtx := setUserInContextInternal(ctx.Context(), user) + newCtx := setUserInContextInternal(ctx.Context(), user, resolveUserPermissionsInternal(ctx.Context(), permResolver, user)) newCtx = context.WithValue(newCtx, ContextKeyCurrentSessionID, sessionID) return huma.WithContext(ctx, newCtx) } // handleApiKeyAuthInternal handles the API-key-present branch. If validation // fails, it writes 401 directly — Bearer is not attempted as fallback. -func handleApiKeyAuthInternal(api huma.API, ctx huma.Context, apiKeyService *services.ApiKeyService, envTokenResolver environmentAccessTokenResolver, next func(huma.Context)) { - if user, ok := tryApiKeyAuthInternal(ctx, apiKeyService); ok { - newCtx := setUserInContextInternal(ctx.Context(), user) +func handleApiKeyAuthInternal(api huma.API, ctx huma.Context, apiKeyService *services.ApiKeyService, permResolver PermissionResolver, envTokenResolver environmentAccessTokenResolver, next func(huma.Context)) { + if user, keyID, ok := tryApiKeyAuthInternal(ctx, apiKeyService); ok { + ps := resolveApiKeyPermissionsInternal(ctx.Context(), permResolver, keyID) + newCtx := setUserInContextInternal(ctx.Context(), user, ps) next(huma.WithContext(ctx, newCtx)) return } if user, ok := tryEnvironmentAccessTokenAuthInternal(ctx, envTokenResolver, ctx.Header(pkgutils.HeaderApiKey)); ok { - newCtx := setUserInContextInternal(ctx.Context(), user) + newCtx := setUserInContextWithSudoInternal(ctx.Context(), user) next(huma.WithContext(ctx, newCtx)) return } _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized: invalid API key") } -func handleBearerAuthInternal(api huma.API, ctx huma.Context, authService *services.AuthService) (huma.Context, bool) { +func handleBearerAuthInternal(api huma.API, ctx huma.Context, authService *services.AuthService, permResolver PermissionResolver) (huma.Context, bool) { user, sessionID, err := tryBearerAuthInternal(ctx, authService) if err == nil && user != nil { - newCtx := setUserInContextInternal(ctx.Context(), user) + ps := resolveUserPermissionsInternal(ctx.Context(), permResolver, user) + newCtx := setUserInContextInternal(ctx.Context(), user, ps) newCtx = context.WithValue(newCtx, ContextKeyCurrentSessionID, sessionID) return huma.WithContext(ctx, newCtx), true } @@ -307,6 +324,34 @@ func handleBearerAuthInternal(api huma.API, ctx huma.Context, authService *servi return nil, false } +// resolveUserPermissionsInternal asks the RoleService for the user's resolved +// PermissionSet. If RoleService is unavailable or the lookup fails (boot-time +// edge cases, broken DB) it returns nil and logs a warning — handlers then +// see deny-all, which is the safe default. +func resolveUserPermissionsInternal(ctx context.Context, permResolver PermissionResolver, user *models.User) *authz.PermissionSet { + if permResolver == nil || user == nil { + return nil + } + ps, err := permResolver.ResolvePermissions(ctx, user) + if err != nil { + slog.WarnContext(ctx, "failed to resolve user permissions", "error", err, "user_id", user.ID) + return nil + } + return ps +} + +func resolveApiKeyPermissionsInternal(ctx context.Context, permResolver PermissionResolver, apiKeyID string) *authz.PermissionSet { + if permResolver == nil || apiKeyID == "" { + return nil + } + ps, err := permResolver.ResolveApiKeyPermissions(ctx, apiKeyID) + if err != nil { + slog.WarnContext(ctx, "failed to resolve api key permissions", "error", err, "api_key_id", apiKeyID) + return nil + } + return ps +} + // extractBearerTokenInternal extracts the JWT token from Authorization header or cookie. func extractBearerTokenInternal(ctx huma.Context) string { // Try Authorization header first @@ -339,10 +384,22 @@ func extractTokenFromCookieHeaderInternal(cookieHeader string) string { return "" } -// setUserInContextInternal adds the authenticated user to the context. -func setUserInContextInternal(ctx context.Context, user *models.User) context.Context { +// setUserInContextInternal adds the authenticated user and the resolved +// PermissionSet to the context. Callers must supply a non-nil PermissionSet; +// pass authz.NewPermissionSet() to express deny-all. +func setUserInContextInternal(ctx context.Context, user *models.User, ps *authz.PermissionSet) context.Context { + if ps == nil { + ps = authz.NewPermissionSet() + } ctx = context.WithValue(ctx, ContextKeyUserID, user.ID) ctx = context.WithValue(ctx, ContextKeyCurrentUser, user) - ctx = context.WithValue(ctx, ContextKeyUserIsAdmin, pkgutils.UserHasRole(user.Roles, "admin")) + ctx = context.WithValue(ctx, ContextKeyUserPermissions, ps) return ctx } + +// setUserInContextWithSudoInternal attaches a sudo PermissionSet (bypasses +// every check) plus the user. Used by the agent token and environment +// access token paths, which are infrastructure-level and not per-user. +func setUserInContextWithSudoInternal(ctx context.Context, user *models.User) context.Context { + return setUserInContextInternal(ctx, user, authz.SudoPermissionSet()) +} diff --git a/backend/api/middleware/auth_test.go b/backend/api/middleware/auth_test.go index a83c1b2168..ded9df8dc0 100644 --- a/backend/api/middleware/auth_test.go +++ b/backend/api/middleware/auth_test.go @@ -55,7 +55,7 @@ func TestNewAuthBridge_AcceptsEnvironmentAccessTokenViaAPIKey(t *testing.T) { } api := humaecho.NewWithGroup(router, apiGroup, humaConfig) - api.UseMiddleware(NewAuthBridge(api, &services.AuthService{}, nil, testEnvironmentAccessResolver{ + api.UseMiddleware(NewAuthBridge(api, &services.AuthService{}, nil, nil, testEnvironmentAccessResolver{ env: &models.Environment{ BaseModel: models.BaseModel{ID: "env-self"}, Name: "Self Target", @@ -174,12 +174,11 @@ func TestNewAuthBridge_OpportunisticAuthOnPublicRoute(t *testing.T) { jwtSecret := "test-secret-please-do-not-use-in-prod" cfg := &config.Config{JWTRefreshExpiry: 24 * time.Hour} - authSvc := services.NewAuthService(userSvc, nil, nil, sessionSvc, jwtSecret, cfg) + authSvc := services.NewAuthService(userSvc, nil, nil, sessionSvc, nil, jwtSecret, cfg) _, err := userSvc.CreateUser(context.Background(), &models.User{ BaseModel: models.BaseModel{ID: "u-logout"}, Username: "logouttest", - Roles: models.StringSlice{"user"}, }) require.NoError(t, err) @@ -207,7 +206,7 @@ func TestNewAuthBridge_OpportunisticAuthOnPublicRoute(t *testing.T) { "BearerAuth": {Type: "http", Scheme: "bearer"}, } api := humaecho.NewWithGroup(router, apiGroup, humaConfig) - api.UseMiddleware(NewAuthBridge(api, authSvc, nil, nil, &config.Config{})) + api.UseMiddleware(NewAuthBridge(api, authSvc, nil, nil, nil, &config.Config{})) var sawSessionID string huma.Register(api, huma.Operation{ diff --git a/backend/api/middleware/role.go b/backend/api/middleware/role.go index 4024bb4dbd..4e77796172 100644 --- a/backend/api/middleware/role.go +++ b/backend/api/middleware/role.go @@ -5,16 +5,46 @@ import ( "net/http" "github.com/danielgtaylor/huma/v2" + + "github.com/getarcaneapp/arcane/backend/pkg/authz" ) -// RequireAdmin returns a per-operation Huma middleware slice that returns 403 -// to non-admin callers. Attach via Operation.Middlewares: +// RequirePermission returns a per-operation Huma middleware that rejects +// callers lacking `perm`. For env-scoped permissions, the env ID is extracted +// from the request path (/environments/{id}/...). For org-level permissions, +// the env ID segment, if any, is ignored. +// +// Attach via Operation.Middlewares: // -// huma.Register(api, huma.Operation{..., Middlewares: middleware.RequireAdmin(api)}, h.Handler) -func RequireAdmin(api huma.API) huma.Middlewares { +// huma.Register(api, huma.Operation{..., Middlewares: middleware.RequirePermission(api, authz.PermContainersStart)}, h.Handler) +func RequirePermission(api huma.API, perm string) huma.Middlewares { + return huma.Middlewares{func(ctx huma.Context, next func(huma.Context)) { + ps, _ := PermissionsFromContext(ctx.Context()) + envID := "" + if authz.IsEnvScoped(perm) { + envID = authz.EnvIDFromPath(ctx.URL().Path) + } + if !ps.Allows(perm, envID) { + if err := huma.WriteErr(api, ctx, http.StatusForbidden, "permission denied: "+perm); err != nil { + slog.WarnContext(ctx.Context(), "failed to write 403 response", "error", err) + } + return + } + next(ctx) + }} +} + +// RequireGlobalAdmin returns a per-operation Huma middleware that rejects any +// caller who is not a global admin (or sudo). Used for operations that are +// intentionally not exposed as delegated permissions — role creation/edits, +// user role assignment, and OIDC mapping management. Keeping these admin-only +// avoids the meta-escalation surface where a holder of `roles:assign` could +// promote themselves via a custom role. +func RequireGlobalAdmin(api huma.API) huma.Middlewares { return huma.Middlewares{func(ctx huma.Context, next func(huma.Context)) { - if !IsAdminFromContext(ctx.Context()) { - if err := huma.WriteErr(api, ctx, http.StatusForbidden, "admin access required"); err != nil { + ps, _ := PermissionsFromContext(ctx.Context()) + if !ps.IsGlobalAdmin() { + if err := huma.WriteErr(api, ctx, http.StatusForbidden, "permission denied: global admin required"); err != nil { slog.WarnContext(ctx.Context(), "failed to write 403 response", "error", err) } return diff --git a/backend/api/middleware/role_test.go b/backend/api/middleware/role_test.go index 9d8f3f3cb2..2bc3933cc6 100644 --- a/backend/api/middleware/role_test.go +++ b/backend/api/middleware/role_test.go @@ -10,67 +10,137 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humaecho" "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" + + "github.com/getarcaneapp/arcane/backend/pkg/authz" ) -func TestRequireAdmin_RejectsNonAdmin(t *testing.T) { +func TestRequirePermission_RejectsCallerMissingPermission(t *testing.T) { router := echo.New() api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { - next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserIsAdmin, false))) + // Caller has containers:list but NOT containers:start. + ps := authz.NewPermissionSet() + ps.AddEnv("env-1", authz.PermContainersList) + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, ps))) }) huma.Register(api, huma.Operation{ - OperationID: "guarded", - Method: http.MethodGet, - Path: "/guarded", - Middlewares: RequireAdmin(api), - }, func(_ context.Context, _ *struct{}) (*struct{}, error) { - t.Fatal("handler must not run for non-admin") + OperationID: "guarded-start", + Method: http.MethodPost, + Path: "/environments/{id}/containers/{cid}/start", + Middlewares: RequirePermission(api, authz.PermContainersStart), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + CID string `path:"cid"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") return nil, nil }) - req := httptest.NewRequest(http.MethodGet, "/api/guarded", nil) + req := httptest.NewRequest(http.MethodPost, "/api/environments/env-1/containers/c/start", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusForbidden, rec.Code) - require.Contains(t, rec.Body.String(), "admin access required") + require.Contains(t, rec.Body.String(), "permission denied: containers:start") } -func TestRequireAdmin_AllowsAdmin(t *testing.T) { +func TestRequirePermission_AllowsCallerWithPermissionOnEnv(t *testing.T) { router := echo.New() api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { - next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserIsAdmin, true))) + ps := authz.NewPermissionSet() + ps.AddEnv("env-1", authz.PermContainersStart) + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, ps))) }) - type guardedAdminOutput struct { + type out struct { Body struct { Success bool `json:"success"` } } - handlerRan := false huma.Register(api, huma.Operation{ - OperationID: "guarded-admin", - Method: http.MethodGet, - Path: "/guarded-admin", - Middlewares: RequireAdmin(api), - }, func(_ context.Context, _ *struct{}) (*guardedAdminOutput, error) { + OperationID: "guarded-start-allow", + Method: http.MethodPost, + Path: "/environments/{id}/containers/{cid}/start", + Middlewares: RequirePermission(api, authz.PermContainersStart), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + CID string `path:"cid"` + }) (*out, error) { handlerRan = true - return &guardedAdminOutput{ - Body: struct { - Success bool `json:"success"` - }{Success: true}, - }, nil + return &out{Body: struct { + Success bool `json:"success"` + }{Success: true}}, nil }) - req := httptest.NewRequest(http.MethodGet, "/api/guarded-admin", nil) + req := httptest.NewRequest(http.MethodPost, "/api/environments/env-1/containers/c/start", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) require.True(t, handlerRan) } + +func TestRequirePermission_EnvScopedDoesNotLeakAcrossEnvs(t *testing.T) { + router := echo.New() + api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) + + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + ps := authz.NewPermissionSet() + // Permission scoped to env-1; request will target env-2. + ps.AddEnv("env-1", authz.PermContainersStart) + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, ps))) + }) + + huma.Register(api, huma.Operation{ + OperationID: "guarded-start-other-env", + Method: http.MethodPost, + Path: "/environments/{id}/containers/{cid}/start", + Middlewares: RequirePermission(api, authz.PermContainersStart), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + CID string `path:"cid"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is scoped to a different env") + return nil, nil + }) + + req := httptest.NewRequest(http.MethodPost, "/api/environments/env-2/containers/c/start", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestRequirePermission_SudoCallerAllowed(t *testing.T) { + router := echo.New() + api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) + + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, authz.SudoPermissionSet()))) + }) + + handlerRan := false + huma.Register(api, huma.Operation{ + OperationID: "guarded-sudo", + Method: http.MethodDelete, + Path: "/users/{userId}", + Middlewares: RequirePermission(api, authz.PermUsersDelete), + }, func(_ context.Context, _ *struct { + UserID string `path:"userId"` + }) (*struct{}, error) { + handlerRan = true + return &struct{}{}, nil + }) + + req := httptest.NewRequest(http.MethodDelete, "/api/users/u-1", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Code) + require.True(t, handlerRan) +} diff --git a/backend/api/ws/handler.go b/backend/api/ws/handler.go index adc3758521..a15208234e 100644 --- a/backend/api/ws/handler.go +++ b/backend/api/ws/handler.go @@ -18,6 +18,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/system" wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" @@ -338,12 +339,12 @@ func NewWebSocketHandler( }, } wsGroup := group.Group("/environments/:id/ws", authMiddleware.WithAdminNotRequired().Add()) - wsGroup.GET("/projects/:projectId/logs", handler.ProjectLogs) - wsGroup.GET("/containers/:containerId/logs", handler.ContainerLogs) - wsGroup.GET("/containers/:containerId/stats", handler.ContainerStats) - wsGroup.GET("/containers/:containerId/terminal", handler.ContainerExec) - wsGroup.GET("/swarm/services/:serviceId/logs", handler.ServiceLogs) - wsGroup.GET("/system/stats", handler.SystemStats) + wsGroup.GET("/projects/:projectId/logs", handler.ProjectLogs, middleware.RequirePermission(authz.PermProjectsLogs)) + wsGroup.GET("/containers/:containerId/logs", handler.ContainerLogs, middleware.RequirePermission(authz.PermContainersLogs)) + wsGroup.GET("/containers/:containerId/stats", handler.ContainerStats, middleware.RequirePermission(authz.PermContainersRead)) + wsGroup.GET("/containers/:containerId/terminal", handler.ContainerExec, middleware.RequirePermission(authz.PermContainersExec)) + wsGroup.GET("/swarm/services/:serviceId/logs", handler.ServiceLogs, middleware.RequirePermission(authz.PermSwarmServicesLogs)) + wsGroup.GET("/system/stats", handler.SystemStats, middleware.RequirePermission(authz.PermSystemRead)) } // ============================================================================ diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 0e90b377d1..f349745263 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -186,6 +186,7 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi } startup.InitializeNonAgentFeatures(appCtx, runtimeCfg, + appServices.Role.EnsureBuiltInRoles, appServices.User.CreateDefaultAdmin, func(ctx context.Context) error { return appServices.ApiKey.ReconcileDefaultAdminAPIKey(ctx, runtimeCfg.AdminStaticAPIKey) @@ -197,6 +198,8 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi ) startup.CleanupUnknownSettings(appCtx, appServices.Settings) + runRoleStartupTasks(appCtx, appServices.Role, cfg, cfg.AgentMode) + // Auto-pair only applies in Edge mode (where the agent's outbound tunnel is the // only path to the manager). Direct mode is passive — the manager dials the agent's // HTTP server on TCP 3553, and the manager-side health-check promotes the env to @@ -210,6 +213,35 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi } } +func runRoleStartupTasks(ctx context.Context, roleService *services.RoleService, cfg *config.Config, agentMode bool) { + if roleService == nil { + return + } + if err := roleService.EnsureBuiltInRoles(ctx); err != nil { + slog.ErrorContext(ctx, "Failed to reconcile built-in roles", "error", err) + } + // Backfill must run AFTER EnsureBuiltInRoles (it references the role IDs + // seeded there) and BEFORE BackfillApiKeyPermissions / AssertGlobalAdminExists + // (both consult the assignments table this populates). + if err := roleService.BackfillLegacyRoleAssignments(ctx); err != nil { + slog.ErrorContext(ctx, "Failed to backfill legacy users.roles into user_role_assignments", "error", err) + } + if err := roleService.BackfillApiKeyPermissions(ctx); err != nil { + slog.WarnContext(ctx, "Failed to backfill API key permissions", "error", err) + } + if cfg != nil { + if err := roleService.ReconcileEnvOidcMappings(ctx, cfg.OidcRoleMappings); err != nil { + slog.ErrorContext(ctx, "Failed to reconcile OIDC_ROLE_MAPPINGS", "error", err) + } + } + if agentMode { + return + } + if err := roleService.AssertGlobalAdminExists(ctx); err != nil { + slog.ErrorContext(ctx, "RBAC global admin guard failed", "error", err) + } +} + func startEdgeTunnelClientIfConfigured(appCtx context.Context, cfg *config.Config, router http.Handler) { managerEndpointConfigured := cfg.ManagerApiUrl != "" if !cfg.EdgeAgent || !managerEndpointConfigured || cfg.AgentToken == "" { @@ -501,6 +533,17 @@ func normalizeTunnelGRPCRequestPathInternal(r *http.Request) *http.Request { } connectMethodPath := tunnelpb.TunnelService_Connect_FullMethodName + + const tunnelConnectPath = "/api/tunnel/connect" + if strings.HasSuffix(r.URL.Path, tunnelConnectPath) { + clone := r.Clone(r.Context()) + cloneURL := *clone.URL + cloneURL.Path = connectMethodPath + clone.URL = &cloneURL + clone.RequestURI = connectMethodPath + return clone + } + idx := strings.Index(r.URL.Path, connectMethodPath) if idx <= 0 { return r diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go index d92f19a887..330aac4fde 100644 --- a/backend/internal/bootstrap/bootstrap_test.go +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -51,6 +51,28 @@ func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) { assert.Equal(t, fullMethodPath, normalized.URL.Path) assert.Equal(t, fullMethodPath, normalized.RequestURI) }) + + t.Run("legacy /api/tunnel/connect is rewritten to gRPC method", func(t *testing.T) { + // Regression: PR #2722 removed this branch, breaking the edge agent's + // gRPC transport. The agent client uses /api/tunnel/connect as its + // gRPC method path so reverse proxies can route tunnel traffic with + // a stable URL instead of the proto-generated gRPC service name. + req := httptest.NewRequest("POST", "/api/tunnel/connect", nil) + normalized := normalizeTunnelGRPCRequestPathInternal(req) + + assert.NotSame(t, req, normalized) + assert.Equal(t, fullMethodPath, normalized.URL.Path) + assert.Equal(t, fullMethodPath, normalized.RequestURI) + }) + + t.Run("nested proxy with legacy /api/tunnel/connect is rewritten", func(t *testing.T) { + req := httptest.NewRequest("POST", "/edge/proxy/api/tunnel/connect", nil) + normalized := normalizeTunnelGRPCRequestPathInternal(req) + + assert.NotSame(t, req, normalized) + assert.Equal(t, fullMethodPath, normalized.URL.Path) + assert.Equal(t, fullMethodPath, normalized.RequestURI) + }) } func TestIsTunnelGRPCRequestInternal(t *testing.T) { diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 265fbfdfcf..460310e4ca 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -41,6 +41,9 @@ var loggerSkipPatterns = []string{ "GET /api/fonts/mono", "GET /api/health", "HEAD /api/health", + // Static branding / PWA assets — browsers re-request these frequently + // and the logs add no signal. + "GET /api/app-images/*", } func shouldLogRequestInternal(c echo.Context) bool { @@ -143,7 +146,8 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) authMiddleware := middleware.NewAuthMiddleware(appServices.Auth, cfg). WithApiKeyValidator(appServices.ApiKey). - WithEnvironmentAccessTokenResolver(appServices.Environment) + WithEnvironmentAccessTokenResolver(appServices.Environment). + WithPermissionResolver(appServices.Role) e.Use(middleware.NewCORSMiddleware(cfg).Add()) apiGroup := e.Group("/api") @@ -217,6 +221,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) Webhook: appServices.Webhook, Vulnerability: appServices.Vulnerability, Dashboard: appServices.Dashboard, + Role: appServices.Role, Config: cfg, } diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index ea8c2400b1..18a18cb78f 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -49,6 +49,7 @@ type Services struct { Font *services.FontService Vulnerability *services.VulnerabilityService Dashboard *services.DashboardService + Role *services.RoleService } func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (svcs *Services, dockerSrvice *services.DockerClientService, err error) { @@ -67,9 +68,10 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Font = services.NewFontService(resources.FS) dockerClient := services.NewDockerClientService(ctx, db, cfg, svcs.Settings) svcs.Docker = dockerClient - svcs.User = services.NewUserService(db) + svcs.Role = services.NewRoleService(db) + svcs.User = services.NewUserService(db).WithRoleService(svcs.Role) svcs.Session = services.NewSessionService(db) - svcs.ApiKey = services.NewApiKeyService(db, svcs.User) + svcs.ApiKey = services.NewApiKeyService(db, svcs.User).WithRoleService(svcs.Role) svcs.ContainerRegistry = services.NewContainerRegistryService(db, func(ctx context.Context) (services.RegistryDaemonClient, error) { return dockerClient.GetClient(ctx) }, svcs.KV) @@ -100,7 +102,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Port = services.NewPortService(svcs.Docker) svcs.Swarm = services.NewSwarmService(svcs.Docker, svcs.Settings, svcs.KV, svcs.ContainerRegistry, svcs.Environment) svcs.Template = services.NewTemplateService(ctx, db, httpClient, svcs.Settings) - svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, svcs.Session, cfg.JWTSecret, cfg) + svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, svcs.Session, svcs.Role, cfg.JWTSecret, cfg) svcs.Oidc = services.NewOidcService(svcs.Auth, svcs.Settings, cfg, httpClient) svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings) svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 8948b037ca..f3d504a615 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -1702,3 +1702,119 @@ func (e *BuildKitDockerExporterError) Error() string { } func (e *BuildKitDockerExporterError) Unwrap() error { return e.Err } + +// ----- RBAC / role service errors ----- + +type RoleNotFoundError struct{} + +func (e *RoleNotFoundError) Error() string { + return "Role not found" +} + +func IsRoleNotFoundError(err error) bool { + return isErrorTypeInternal[*RoleNotFoundError](err) +} + +type RoleBuiltInError struct{} + +func (e *RoleBuiltInError) Error() string { + return "Built-in role cannot be modified" +} + +func IsRoleBuiltInError(err error) bool { + return isErrorTypeInternal[*RoleBuiltInError](err) +} + +type RoleNameTakenError struct{} + +func (e *RoleNameTakenError) Error() string { + return "Role name already in use" +} + +func IsRoleNameTakenError(err error) bool { + return isErrorTypeInternal[*RoleNameTakenError](err) +} + +type UnknownPermissionError struct { + Perm string +} + +func (e *UnknownPermissionError) Error() string { + return fmt.Sprintf("Unknown permission: %s", e.Perm) +} + +func IsUnknownPermissionError(err error) bool { + return isErrorTypeInternal[*UnknownPermissionError](err) +} + +// RolePermissionEscalationError is returned when a caller attempts to author a +// role containing a permission they do not themselves hold at global scope. +// Used by the CreateRole and UpdateRole handlers as defense-in-depth alongside +// the RequireGlobalAdmin middleware. +type RolePermissionEscalationError struct { + Perm string +} + +func (e *RolePermissionEscalationError) Error() string { + return fmt.Sprintf("cannot grant a permission you do not hold: %s", e.Perm) +} + +func IsRolePermissionEscalationError(err error) bool { + return isErrorTypeInternal[*RolePermissionEscalationError](err) +} + +// InvalidRoleAssignmentError is returned when SetUserAssignments is called with +// a RoleID or EnvironmentID that doesn't exist in the database. Surfaces as a +// 400 Bad Request so callers see a descriptive message instead of an opaque +// FK-violation 500 from the underlying tx.Create. +type InvalidRoleAssignmentError struct { + RoleID string + EnvironmentID string +} + +func (e *InvalidRoleAssignmentError) Error() string { + if e.RoleID != "" { + return fmt.Sprintf("invalid role assignment: role %q does not exist", e.RoleID) + } + if e.EnvironmentID != "" { + return fmt.Sprintf("invalid role assignment: environment %q does not exist", e.EnvironmentID) + } + return "invalid role assignment" +} + +func IsInvalidRoleAssignmentError(err error) bool { + return isErrorTypeInternal[*InvalidRoleAssignmentError](err) +} + +type OidcMappingNotFoundError struct{} + +func (e *OidcMappingNotFoundError) Error() string { + return "OIDC role mapping not found" +} + +func IsOidcMappingNotFoundError(err error) bool { + return isErrorTypeInternal[*OidcMappingNotFoundError](err) +} + +// OidcMappingEnvManagedError is returned when an API caller attempts to mutate +// an OIDC role mapping that was declared via OIDC_ROLE_MAPPINGS. Env-managed +// rows can only be changed by editing the env var and restarting. +type OidcMappingEnvManagedError struct{} + +func (e *OidcMappingEnvManagedError) Error() string { + return "OIDC role mapping is managed by OIDC_ROLE_MAPPINGS and cannot be edited at runtime" +} + +func IsOidcMappingEnvManagedError(err error) bool { + return isErrorTypeInternal[*OidcMappingEnvManagedError](err) +} + +type NoGlobalAdminRemainsError struct{} + +func (e *NoGlobalAdminRemainsError) Error() string { + return "At least one user must retain a global Admin role assignment" +} + +func IsNoGlobalAdminRemainsError(err error) bool { + return isErrorTypeInternal[*NoGlobalAdminRemainsError](err) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4893dc7a98..11875a702f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -47,13 +47,17 @@ type Config struct { OidcClientSecret string `env:"OIDC_CLIENT_SECRET" default:"" options:"file"` OidcIssuerURL string `env:"OIDC_ISSUER_URL" default:""` OidcScopes string `env:"OIDC_SCOPES" default:"openid email profile"` - OidcAdminClaim string `env:"OIDC_ADMIN_CLAIM" default:""` - OidcAdminValue string `env:"OIDC_ADMIN_VALUE" default:""` + OidcGroupsClaim string `env:"OIDC_GROUPS_CLAIM" default:"groups"` OidcSkipTlsVerify bool `env:"OIDC_SKIP_TLS_VERIFY" default:"false"` OidcAutoRedirectToProvider bool `env:"OIDC_AUTO_REDIRECT_TO_PROVIDER" default:"false"` OidcProviderName string `env:"OIDC_PROVIDER_NAME" default:""` OidcProviderLogoUrl string `env:"OIDC_PROVIDER_LOGO_URL" default:""` OidcMobileRedirectUris string `env:"OIDC_MOBILE_REDIRECT_URIS" default:"arcane-mobile://oidc-callback"` + // OidcRoleMappings declaratively defines OIDC group→role mappings as a + // JSON array of role.OidcRoleMappingSpec. Reconciled into source='env' + // rows on every boot; rows are read-only at runtime. Supports *_FILE for + // Docker secrets. Leave empty to manage mappings purely via the UI/API. + OidcRoleMappings string `env:"OIDC_ROLE_MAPPINGS" default:"" options:"file"` PUID string `env:"PUID" default:""` PGID string `env:"PGID" default:""` diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index fbe348d0ff..35b6562112 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -230,16 +230,16 @@ var expectedEnvConfigVars = []string{ "LOG_JSON", "LOG_LEVEL", "MANAGER_API_URL", - "OIDC_ADMIN_CLAIM", - "OIDC_ADMIN_VALUE", "OIDC_AUTO_REDIRECT_TO_PROVIDER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_ENABLED", + "OIDC_GROUPS_CLAIM", "OIDC_ISSUER_URL", "OIDC_MOBILE_REDIRECT_URIS", "OIDC_PROVIDER_LOGO_URL", "OIDC_PROVIDER_NAME", + "OIDC_ROLE_MAPPINGS", "OIDC_SCOPES", "OIDC_SKIP_TLS_VERIFY", "PGID", @@ -304,14 +304,13 @@ var expectedSettingOverrideKeys = []string{ "maxImageUploadSize", "mobileNavigationMode", "mobileNavigationShowLabels", - "oidcAdminClaim", - "oidcAdminValue", "oidcAuthorizationEndpoint", "oidcAutoRedirectToProvider", "oidcClientId", "oidcClientSecret", "oidcDeviceAuthorizationEndpoint", "oidcEnabled", + "oidcGroupsClaim", "oidcIssuerUrl", "oidcJwksEndpoint", "oidcMergeAccounts", diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index fba051dbd7..c1bdc60924 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -11,28 +11,47 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" "github.com/labstack/echo/v4" ) +// Echo context keys, kept aligned with api/middleware/auth.go constants so +// shared handlers can read them regardless of which auth layer attached them. +const ( + echoCtxKeyUserID = "userID" + echoCtxKeyCurrentUser = "currentUser" + echoCtxKeyUserPermissions = "userPermissions" + echoCtxKeyAuthMethod = "authMethod" + echoCtxKeySessionID = "currentSessionID" +) + type AuthOptions struct { AdminRequired bool SuccessOptional bool } type ApiKeyValidator interface { - ValidateApiKey(ctx context.Context, rawKey string) (*models.User, error) + ValidateApiKeyWithID(ctx context.Context, rawKey string) (*models.User, string, error) } type EnvironmentAccessTokenResolver interface { ResolveEnvironmentByAccessToken(ctx context.Context, token string) (*models.Environment, error) } +// PermissionResolver resolves a caller's effective permission set. Implemented +// by services.RoleService; kept as an interface so tests can stub it. +type PermissionResolver interface { + ResolvePermissions(ctx context.Context, user *models.User) (*authz.PermissionSet, error) + ResolveApiKeyPermissions(ctx context.Context, apiKeyID string) (*authz.PermissionSet, error) +} + type AuthMiddleware struct { authService *services.AuthService apiKeyValidator ApiKeyValidator envTokenResolver EnvironmentAccessTokenResolver + roleResolver PermissionResolver cfg *config.Config options AuthOptions } @@ -57,6 +76,12 @@ func (m *AuthMiddleware) WithEnvironmentAccessTokenResolver(resolver Environment return &clone } +func (m *AuthMiddleware) WithPermissionResolver(resolver PermissionResolver) *AuthMiddleware { + clone := *m + clone.roleResolver = resolver + return &clone +} + func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware { clone := *m clone.options.AdminRequired = false @@ -69,6 +94,30 @@ func (m *AuthMiddleware) WithAdminRequired() *AuthMiddleware { return &clone } +// RequirePermission returns an Echo middleware that rejects callers lacking +// `perm` for the environment ID in the request path (or globally for org-level +// permissions). Use on streaming/WS routes that aren't served by Huma. Expects +// the caller's PermissionSet to already be on the Echo context via the +// AuthMiddleware (i.e., chain it AFTER auth). +func RequirePermission(perm string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ps, _ := c.Get(echoCtxKeyUserPermissions).(*authz.PermissionSet) + envID := "" + if authz.IsEnvScoped(perm) { + envID = authz.EnvIDFromPath(c.Request().URL.Path) + } + if !ps.Allows(perm, envID) { + return c.JSON(http.StatusForbidden, models.APIError{ + Code: "FORBIDDEN", + Message: "permission denied: " + perm, + }) + } + return next(c) + } + } +} + func (m *AuthMiddleware) Add() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -130,19 +179,19 @@ func (m *AuthMiddleware) managerAuth(ctx context.Context, c echo.Context, next e // First, check for API key in X-API-Key header if apiKey := req.Header.Get(pkgutils.HeaderApiKey); apiKey != "" { if m.apiKeyValidator != nil { - user, err := m.apiKeyValidator.ValidateApiKey(ctx, apiKey) + user, keyID, err := m.apiKeyValidator.ValidateApiKeyWithID(ctx, apiKey) if err == nil && user != nil { - isAdmin := pkgutils.UserHasRole(user.Roles, "admin") - if m.options.AdminRequired && !isAdmin { + ps := m.resolveApiKeyPermissionsOrDeny(ctx, keyID) + if m.options.AdminRequired && !ps.IsGlobalAdmin() { return c.JSON(http.StatusForbidden, models.APIError{ Code: "FORBIDDEN", Message: "You don't have permission to access this resource", }) } - c.Set("userID", user.ID) - c.Set("currentUser", user) - c.Set("userIsAdmin", isAdmin) - c.Set("authMethod", "api_key") + c.Set(echoCtxKeyUserID, user.ID) + c.Set(echoCtxKeyCurrentUser, user) + c.Set(echoCtxKeyUserPermissions, ps) + c.Set(echoCtxKeyAuthMethod, "api_key") return next(c) } } @@ -186,21 +235,51 @@ func (m *AuthMiddleware) managerAuth(ctx context.Context, c echo.Context, next e }) } - isAdmin := pkgutils.UserHasRole(user.Roles, "admin") - if m.options.AdminRequired && !isAdmin { + ps := m.resolvePermissionsOrDeny(ctx, user) + if m.options.AdminRequired && !ps.IsGlobalAdmin() { return c.JSON(http.StatusForbidden, models.APIError{ Code: "FORBIDDEN", Message: "You don't have permission to access this resource", }) } - c.Set("userID", user.ID) - c.Set("currentUser", user) - c.Set("currentSessionID", sessionID) - c.Set("userIsAdmin", isAdmin) + c.Set(echoCtxKeyUserID, user.ID) + c.Set(echoCtxKeyCurrentUser, user) + c.Set(echoCtxKeySessionID, sessionID) + c.Set(echoCtxKeyUserPermissions, ps) return next(c) } +// resolvePermissionsOrDeny returns the user's permission set, or an empty +// (deny-all) set if the resolver is unavailable or fails. Resolver failures +// are logged. +func (m *AuthMiddleware) resolvePermissionsOrDeny(ctx context.Context, user *models.User) *authz.PermissionSet { + if m.roleResolver == nil || user == nil { + return authz.NewPermissionSet() + } + ps, err := m.roleResolver.ResolvePermissions(ctx, user) + if err != nil || ps == nil { + slog.WarnContext(ctx, "failed to resolve user permissions for Echo auth", "error", err) + return authz.NewPermissionSet() + } + return ps +} + +// resolveApiKeyPermissionsOrDeny returns the API key's per-key PermissionSet, +// or an empty (deny-all) set on failure. Falling back to the owner's role +// permissions would defeat per-key scoping, so failures are explicitly denied. +func (m *AuthMiddleware) resolveApiKeyPermissionsOrDeny(ctx context.Context, apiKeyID string) *authz.PermissionSet { + if m.roleResolver == nil || apiKeyID == "" { + return authz.NewPermissionSet() + } + ps, err := m.roleResolver.ResolveApiKeyPermissions(ctx, apiKeyID) + if err != nil || ps == nil { + slog.WarnContext(ctx, "failed to resolve api key permissions for Echo auth", "error", err) + return authz.NewPermissionSet() + } + return ps +} + func (m *AuthMiddleware) resolveEnvironmentAccessToken(ctx context.Context, token string) (*models.Environment, bool) { if m.envTokenResolver == nil { return nil, false @@ -223,24 +302,22 @@ func agentSudoInternal(c echo.Context) { BaseModel: models.BaseModel{ID: "agent"}, Email: new("agent@getarcane.app"), Username: "agent", - Roles: []string{"admin"}, } - c.Set("userID", agentUser.ID) - c.Set("currentUser", agentUser) - c.Set("userIsAdmin", true) - c.Set("authMethod", "agent_token") + c.Set(echoCtxKeyUserID, agentUser.ID) + c.Set(echoCtxKeyCurrentUser, agentUser) + c.Set(echoCtxKeyUserPermissions, authz.SudoPermissionSet()) + c.Set(echoCtxKeyAuthMethod, "agent_token") } func environmentSudoInternal(c echo.Context, env *models.Environment) { envUser := &models.User{ BaseModel: models.BaseModel{ID: "environment:" + env.ID}, Username: env.Name, - Roles: []string{"admin"}, } - c.Set("userID", envUser.ID) - c.Set("currentUser", envUser) - c.Set("userIsAdmin", true) - c.Set("authMethod", "environment_access_token") + c.Set(echoCtxKeyUserID, envUser.ID) + c.Set(echoCtxKeyCurrentUser, envUser) + c.Set(echoCtxKeyUserPermissions, authz.SudoPermissionSet()) + c.Set(echoCtxKeyAuthMethod, "environment_access_token") } func extractBearerOrCookieTokenInternal(c echo.Context) string { diff --git a/backend/internal/models/rbac.go b/backend/internal/models/rbac.go new file mode 100644 index 0000000000..5be9a7161f --- /dev/null +++ b/backend/internal/models/rbac.go @@ -0,0 +1,72 @@ +package models + +// Role is a named permission set. Built-in roles (Admin, Editor, Deployer, +// Viewer) are seeded by migration 054 and cannot be edited or deleted. +type Role struct { + Name string `json:"name" gorm:"column:name;not null;uniqueIndex" sortable:"true"` + Description *string `json:"description,omitempty" gorm:"column:description"` + Permissions StringSlice `json:"permissions" gorm:"column:permissions;type:text;not null"` + BuiltIn bool `json:"builtIn" gorm:"column:built_in;not null;default:false" sortable:"true"` + BaseModel +} + +func (Role) TableName() string { return "roles" } + +// UserRoleAssignment binds a user to a role, optionally scoped to one +// environment. EnvironmentID == nil means "global" — the role's permissions +// apply across all environments AND to org-level resources. +// +// Source distinguishes manual assignments (managed by admins via the UI) from +// assignments synthesized from OIDC group mappings on every login. +type UserRoleAssignment struct { + UserID string `json:"userId" gorm:"column:user_id;not null;index"` + RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id;index"` + Source string `json:"source" gorm:"column:source;not null;default:'manual'"` + BaseModel +} + +func (UserRoleAssignment) TableName() string { return "user_role_assignments" } + +// Assignment source values stored in UserRoleAssignment.Source. +const ( + RoleAssignmentSourceManual = "manual" + RoleAssignmentSourceOidc = "oidc" +) + +// ApiKeyPermission is one permission grant on an API key, optionally scoped to +// a single environment. Permissions are stored per-row (rather than as a JSON +// column on api_keys) so we can index by (api_key_id, permission) for fast +// lookups in the auth bridge. +type ApiKeyPermission struct { + ApiKeyID string `json:"apiKeyId" gorm:"column:api_key_id;not null;index"` + Permission string `json:"permission" gorm:"column:permission;not null"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` + BaseModel +} + +func (ApiKeyPermission) TableName() string { return "api_key_permissions" } + +// OidcRoleMapping maps an OIDC group/claim value to a role assignment. On +// every OIDC login, the auth service replaces all source='oidc' rows on the +// user with assignments derived from the mappings whose ClaimValue matches a +// claim returned by the IdP. +// +// Source distinguishes UI/API-managed rows (the default) from env-declared +// rows reconciled at boot from OIDC_ROLE_MAPPINGS. Env-managed rows are +// read-only via the API — they can only be changed by editing the env var +// and restarting. +type OidcRoleMapping struct { + ClaimValue string `json:"claimValue" gorm:"column:claim_value;not null;index"` + RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` + Source string `json:"source" gorm:"column:source;not null;default:'manual'"` + BaseModel +} + +func (OidcRoleMapping) TableName() string { return "oidc_role_mappings" } + +const ( + OidcMappingSourceManual = "manual" + OidcMappingSourceEnv = "env" +) diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index f1cde96f40..7d57eb219f 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -73,7 +73,7 @@ type Settings struct { SwarmStackSourcesDirectory SettingVariable `key:"swarmStackSourcesDirectory,envOverride" meta:"label=Swarm Stack Sources Directory;type=text;keywords=swarm,stacks,stack,source,sources,directory,path,folder,location,storage,compose,env;category=internal;description=Configure where swarm stack source files are stored"` DiskUsagePath SettingVariable `key:"diskUsagePath" meta:"label=Disk Usage Path;type=text;keywords=disk,usage,path,storage,folder,files;category=general;description=Path used for disk usage calculations"` BaseServerURL SettingVariable `key:"baseServerUrl" meta:"label=Base Server URL;type=text;keywords=base,url,server,domain,host,endpoint,address,link;category=general;description=Set the base URL for the application"` - EnableGravatar SettingVariable `key:"enableGravatar" meta:"label=Enable Gravatar;type=boolean;keywords=gravatar,avatar,profile,picture,image,user,photo;category=general;description=Enable Gravatar profile pictures for users"` + EnableGravatar SettingVariable `key:"enableGravatar,authrequired" meta:"label=Enable Gravatar;type=boolean;keywords=gravatar,avatar,profile,picture,image,user,photo;category=general;description=Enable Gravatar profile pictures for users"` DefaultShell SettingVariable `key:"defaultShell" meta:"label=Default Shell;type=text;keywords=shell,default,shellpath,path,login;category=general;description=Default shell to use for commands"` EnvironmentHealthInterval SettingVariable `key:"environmentHealthInterval" meta:"label=Environment Health Check Interval;type=cron;keywords=environment,health,check,interval,frequency,heartbeat,status,monitoring,uptime,jobs,schedule;description=How often to check environment connectivity (cron expression)" catmeta:"id=jobschedule;title=Job Schedule;icon=jobs;url=/settings/jobs;description=Configure how often Arcane background jobs run"` ApplicationTheme SettingVariable `key:"applicationTheme,public,local" meta:"label=Application Theme;type=select;keywords=theme,appearance,style,visual,palette,background,interface,ui;category=appearance;description=Choose the overall visual theme for the application"` @@ -146,8 +146,7 @@ type Settings struct { OidcJwksEndpoint SettingVariable `key:"oidcJwksEndpoint,envOverride" meta:"label=OIDC JWKS Endpoint;type=text;keywords=oidc,jwks,keys,endpoint,oauth,openid;category=authentication;description=Override OIDC JWKS endpoint"` OidcDeviceAuthorizationEndpoint SettingVariable `key:"oidcDeviceAuthorizationEndpoint,envOverride" meta:"label=OIDC Device Authorization Endpoint;type=text;keywords=oidc,device,authorization,endpoint,oauth,openid,cli;category=authentication;description=Override OIDC device authorization endpoint for CLI authentication"` OidcScopes SettingVariable `key:"oidcScopes,authrequired,envOverride" meta:"label=OIDC Scopes;type=text;keywords=oidc,scopes,oauth,openid,permissions;category=authentication;description=OIDC scopes to request"` - OidcAdminClaim SettingVariable `key:"oidcAdminClaim,authrequired,envOverride" meta:"label=OIDC Admin Claim;type=text;keywords=oidc,admin,claim,role,group;category=authentication;description=Claim name for admin role mapping"` - OidcAdminValue SettingVariable `key:"oidcAdminValue,authrequired,envOverride" meta:"label=OIDC Admin Value;type=text;keywords=oidc,admin,value,role,group;category=authentication;description=Claim value that grants admin access"` + OidcGroupsClaim SettingVariable `key:"oidcGroupsClaim,authrequired,envOverride" meta:"label=OIDC Groups Claim;type=text;keywords=oidc,groups,claim,role,mapping,rbac;category=authentication;description=Claim name to read group memberships from for role mapping (default: groups)"` OidcSkipTlsVerify SettingVariable `key:"oidcSkipTlsVerify,authrequired,envOverride" meta:"label=OIDC Skip TLS Verify;type=boolean;keywords=oidc,tls,verify,skip,insecure;category=authentication;description=Skip TLS verification for OIDC provider"` OidcAutoRedirectToProvider SettingVariable `key:"oidcAutoRedirectToProvider,public,envOverride" meta:"label=OIDC Auto Redirect;type=boolean;keywords=oidc,auto,redirect,automatic,login,provider,sso;category=authentication;description=Automatically redirect to OIDC provider on login page"` OidcMergeAccounts SettingVariable `key:"oidcMergeAccounts,authrequired,envOverride" meta:"label=OIDC Account Merging;type=boolean;keywords=oidc,merge,link,accounts,email,match,existing,users,combine;category=authentication;description=Allow OIDC logins to merge with existing accounts by email"` @@ -370,13 +369,10 @@ type OidcConfig struct { JwksURI string `json:"jwksUri,omitempty"` DeviceAuthorizationEndpoint string `json:"deviceAuthorizationEndpoint,omitempty"` - // Admin mapping: evaluate this claim to grant admin. - // Examples: - // - adminClaim: "admin", adminValue: "true" (boolean or string "true") - // - adminClaim: "roles", adminValue: "admin" (array membership) - // - adminClaim: "realm_access.roles", adminValue: "admin" (Keycloak) - AdminClaim string `json:"adminClaim,omitempty"` - AdminValue string `json:"adminValue,omitempty"` + // GroupsClaim is the claim path Arcane reads group memberships from on + // every OIDC login. Matched against oidc_role_mappings to produce role + // assignments. Default: "groups". + GroupsClaim string `json:"groupsClaim,omitempty"` SkipTlsVerify bool `json:"skipTlsVerify"` } diff --git a/backend/internal/models/settings_test.go b/backend/internal/models/settings_test.go index 97e214ae2f..81bdd2ee25 100644 --- a/backend/internal/models/settings_test.go +++ b/backend/internal/models/settings_test.go @@ -20,8 +20,7 @@ func TestSettings_ToSettingVariableSlice_Visibility(t *testing.T) { OidcClientId: SettingVariable{Value: "client-id"}, OidcIssuerUrl: SettingVariable{Value: "https://issuer.example"}, OidcScopes: SettingVariable{Value: "openid email profile"}, - OidcAdminClaim: SettingVariable{Value: "groups"}, - OidcAdminValue: SettingVariable{Value: "_arcane_admins"}, + OidcGroupsClaim: SettingVariable{Value: "groups"}, OidcSkipTlsVerify: SettingVariable{Value: "false"}, OidcMergeAccounts: SettingVariable{Value: "true"}, MobileNavigationMode: SettingVariable{Value: "floating"}, @@ -48,13 +47,12 @@ func TestSettings_ToSettingVariableSlice_Visibility(t *testing.T) { require.Contains(t, nonAdminKeys, "oidcClientId") require.Contains(t, nonAdminKeys, "oidcIssuerUrl") require.Contains(t, nonAdminKeys, "oidcScopes") - require.Contains(t, nonAdminKeys, "oidcAdminClaim") - require.Contains(t, nonAdminKeys, "oidcAdminValue") + require.Contains(t, nonAdminKeys, "oidcGroupsClaim") require.Contains(t, nonAdminKeys, "oidcSkipTlsVerify") require.Contains(t, nonAdminKeys, "oidcMergeAccounts") require.Contains(t, nonAdminKeys, "mobileNavigationMode") require.Contains(t, nonAdminKeys, "keyboardShortcutsEnabled") - require.NotContains(t, nonAdminKeys, "enableGravatar") + require.Contains(t, nonAdminKeys, "enableGravatar") require.NotContains(t, nonAdminKeys, "baseServerUrl") require.NotContains(t, nonAdminKeys, "defaultShell") require.NotContains(t, nonAdminKeys, "oidcClientSecret") diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index a7558b6965..2669f53b98 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -5,15 +5,14 @@ import ( ) type User struct { - Username string `json:"username" sortable:"true"` - PasswordHash string `json:"-" gorm:"column:password_hash"` - DisplayName *string `json:"displayName,omitempty" gorm:"column:display_name" sortable:"true"` - Email *string `json:"email,omitempty" sortable:"true"` - Roles StringSlice `json:"roles" gorm:"type:text"` - OidcSubjectId *string `json:"oidcSubjectId,omitempty" gorm:"column:oidc_subject_id"` - LastLogin *time.Time `json:"lastLogin,omitempty" gorm:"column:last_login" sortable:"true"` - Locale *string `json:"locale,omitempty" gorm:"column:locale"` - RequiresPasswordChange bool `json:"requiresPasswordChange" gorm:"column:requires_password_change"` + Username string `json:"username" sortable:"true"` + PasswordHash string `json:"-" gorm:"column:password_hash"` + DisplayName *string `json:"displayName,omitempty" gorm:"column:display_name" sortable:"true"` + Email *string `json:"email,omitempty" sortable:"true"` + OidcSubjectId *string `json:"oidcSubjectId,omitempty" gorm:"column:oidc_subject_id"` + LastLogin *time.Time `json:"lastLogin,omitempty" gorm:"column:last_login" sortable:"true"` + Locale *string `json:"locale,omitempty" gorm:"column:locale"` + RequiresPasswordChange bool `json:"requiresPasswordChange" gorm:"column:requires_password_change"` // OIDC provider tokens OidcAccessToken *string `json:"-" gorm:"type:text"` diff --git a/backend/internal/services/api_key_service.go b/backend/internal/services/api_key_service.go index 6f655c26a5..183d5977d4 100644 --- a/backend/internal/services/api_key_service.go +++ b/backend/internal/services/api_key_service.go @@ -12,7 +12,9 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" "github.com/getarcaneapp/arcane/types/apikey" "gorm.io/gorm" ) @@ -42,6 +44,7 @@ var defaultAdminAPIKeyDescription = func() *string { type ApiKeyService struct { db *database.DB userService *UserService + roleService *RoleService argon2Params *Argon2Params } @@ -53,6 +56,16 @@ func NewApiKeyService(db *database.DB, userService *UserService) *ApiKeyService } } +// WithRoleService wires the RoleService dependency. Separated from the +// constructor to break the bootstrap-ordering cycle between ApiKeyService and +// RoleService (RoleService.BackfillApiKeyPermissions needs ApiKeyService to +// exist when it runs, while permission-validated CreateApiKey needs the +// RoleService). +func (s *ApiKeyService) WithRoleService(roleService *RoleService) *ApiKeyService { + s.roleService = roleService + return s +} + func (s *ApiKeyService) generateApiKey() (string, error) { bytes := make([]byte, apiKeyLength) if _, err := rand.Read(bytes); err != nil { @@ -100,12 +113,72 @@ func (s *ApiKeyService) markApiKeyUsedAsync(ctx context.Context, keyID string) { } func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, req apikey.CreateApiKey) (*apikey.ApiKeyCreatedDto, error) { + if err := s.validateGrantsAgainstUserInternal(ctx, userID, req.Permissions); err != nil { + return nil, err + } rawKey, err := s.generateApiKey() if err != nil { return nil, err } - return s.createAPIKeyWithRawKey(ctx, &userID, rawKey, req, nil, nil) + created, err := s.createAPIKeyWithRawKey(ctx, &userID, rawKey, req, nil, nil) + if err != nil { + return nil, err + } + if s.roleService != nil { + grants := toApiKeyPermissionRowsInternal(created.ID, req.Permissions) + if err := s.roleService.SetApiKeyPermissions(ctx, created.ID, grants); err != nil { + return nil, fmt.Errorf("failed to persist api key permissions: %w", err) + } + // Re-load the just-persisted grants into the response DTO so the + // frontend doesn't see `"permissions": null` on a successful create. + created.Permissions = s.loadKeyGrantsInternal(ctx, created.ID) + } + return created, nil +} + +// ErrApiKeyPermissionEscalation is returned when a caller attempts to grant an +// API key permissions they themselves do not hold. +var ErrApiKeyPermissionEscalation = errors.New("cannot grant a permission you do not have") + +// validateGrantsAgainstUserInternal refuses requests that would grant the new key +// permissions that the requesting user doesn't hold. Sudo callers bypass this +// (they always pass). If RoleService is unavailable validation is skipped (used +// only in the boot-bootstrap path where no human caller exists). +func (s *ApiKeyService) validateGrantsAgainstUserInternal(ctx context.Context, userID string, grants []apikey.PermissionGrant) error { + if s.roleService == nil || len(grants) == 0 { + return nil + } + user, err := s.userService.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("load user for permission validation: %w", err) + } + ps, err := s.roleService.ResolvePermissions(ctx, user) + if err != nil { + return fmt.Errorf("resolve user permissions: %w", err) + } + for _, g := range grants { + envID := "" + if g.EnvironmentID != nil { + envID = *g.EnvironmentID + } + if !ps.Allows(g.Permission, envID) { + return fmt.Errorf("%w: %s (env=%q)", ErrApiKeyPermissionEscalation, g.Permission, envID) + } + } + return nil +} + +func toApiKeyPermissionRowsInternal(apiKeyID string, grants []apikey.PermissionGrant) []models.ApiKeyPermission { + out := make([]models.ApiKeyPermission, len(grants)) + for i, g := range grants { + out[i] = models.ApiKeyPermission{ + ApiKeyID: apiKeyID, + Permission: g.Permission, + EnvironmentID: g.EnvironmentID, + } + } + return out } func (s *ApiKeyService) createAPIKeyWithRawKey( @@ -152,6 +225,17 @@ func isStaticAPIKeyInternal(ak models.ApiKey) bool { return ak.ManagedBy != nil && *ak.ManagedBy == managedByAdminBootstrap } +// isEnvironmentBootstrapKeyInternal identifies the auto-generated key minted by +// CreateEnvironmentApiKey for environment pairing. Those keys have no owner +// (UserID == nil) and are scoped to a single environment. They carry full +// env-scoped permissions and must never be hand-edited or deleted via the API +// — manually clearing the grants would silently break the paired edge agent +// the next time it tries to authenticate. Cascade-delete still applies when +// the environment row itself is removed. +func isEnvironmentBootstrapKeyInternal(ak models.ApiKey) bool { + return ak.UserID == nil && ak.EnvironmentID != nil +} + func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey { return apikey.ApiKey{ ID: ak.ID, @@ -160,6 +244,7 @@ func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey { KeyPrefix: ak.KeyPrefix, UserID: ak.UserID, IsStatic: isStaticAPIKeyInternal(*ak), + IsBootstrap: isEnvironmentBootstrapKeyInternal(*ak), ExpiresAt: ak.ExpiresAt, LastUsedAt: ak.LastUsedAt, CreatedAt: ak.CreatedAt, @@ -167,6 +252,14 @@ func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey { } } +// toAPIKeyDTOWithPermissionsInternal is like toAPIKeyDTOInternal but +// additionally attaches the key's persisted permission grants. +func (s *ApiKeyService) toAPIKeyDTOWithPermissionsInternal(ctx context.Context, ak *models.ApiKey) apikey.ApiKey { + dto := toAPIKeyDTOInternal(ak) + dto.Permissions = s.loadKeyGrantsInternal(ctx, ak.ID) + return dto +} + func (s *ApiKeyService) CreateDefaultAdminAPIKey(ctx context.Context, userID, rawKey string) (*apikey.ApiKeyCreatedDto, error) { return s.createAPIKeyWithRawKey(ctx, &userID, rawKey, apikey.CreateApiKey{ Name: defaultAdminAPIKeyName, @@ -316,10 +409,36 @@ func (s *ApiKeyService) CreateEnvironmentApiKey(ctx context.Context, environment envIDShort = environmentID[:8] } name := fmt.Sprintf("Environment Bootstrap Key - %s", envIDShort) - return s.createAPIKeyWithRawKey(ctx, nil, rawKey, apikey.CreateApiKey{ + created, err := s.createAPIKeyWithRawKey(ctx, nil, rawKey, apikey.CreateApiKey{ Name: name, Description: new("Auto-generated key for environment pairing"), }, nil, &environmentID) + if err != nil { + return nil, err + } + // Env-bootstrap keys are infrastructure-level credentials used by the agent + // pairing flow; they must always carry every permission scoped to their + // environment. Without this seed, the per-key permission resolver returns + // an empty set on any request authenticated by this key and every + // downstream RequirePermission check fails with 403. + if s.roleService != nil { + all := authz.AllPermissions() + grants := make([]models.ApiKeyPermission, len(all)) + for i, p := range all { + grants[i] = models.ApiKeyPermission{ + ApiKeyID: created.ID, + Permission: p, + EnvironmentID: &environmentID, + } + } + if err := s.roleService.SetApiKeyPermissions(ctx, created.ID, grants); err != nil { + return nil, fmt.Errorf("failed to persist environment bootstrap key permissions: %w", err) + } + // Re-load grants into the response DTO so callers see the seeded + // permissions immediately, not null. + created.Permissions = s.loadKeyGrantsInternal(ctx, created.ID) + } + return created, nil } func (s *ApiKeyService) GetApiKey(ctx context.Context, id string) (*apikey.ApiKey, error) { @@ -330,19 +449,8 @@ func (s *ApiKeyService) GetApiKey(ctx context.Context, id string) (*apikey.ApiKe } return nil, fmt.Errorf("failed to get API key: %w", err) } - - return &apikey.ApiKey{ - ID: ak.ID, - Name: ak.Name, - Description: ak.Description, - KeyPrefix: ak.KeyPrefix, - UserID: ak.UserID, - IsStatic: isStaticAPIKeyInternal(ak), - ExpiresAt: ak.ExpiresAt, - LastUsedAt: ak.LastUsedAt, - CreatedAt: ak.CreatedAt, - UpdatedAt: ak.UpdatedAt, - }, nil + dto := s.toAPIKeyDTOWithPermissionsInternal(ctx, &ak) + return &dto, nil } func (s *ApiKeyService) ListApiKeys(ctx context.Context, params pagination.QueryParams) ([]apikey.ApiKey, pagination.Response, error) { @@ -363,14 +471,17 @@ func (s *ApiKeyService) ListApiKeys(ctx context.Context, params pagination.Query } result := make([]apikey.ApiKey, len(apiKeys)) - for i, ak := range apiKeys { - result[i] = toAPIKeyDTOInternal(&ak) + for i := range apiKeys { + // Include per-key permission grants so the edit form preloads with the + // key's actual current grants. Without this, the form starts empty and + // "Save" would wipe whatever grants the DB has. + result[i] = s.toAPIKeyDTOWithPermissionsInternal(ctx, &apiKeys[i]) } return result, paginationResp, nil } -func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey.UpdateApiKey) (*apikey.ApiKey, error) { +func (s *ApiKeyService) UpdateApiKey(ctx context.Context, callerUserID, id string, req apikey.UpdateApiKey) (*apikey.ApiKey, error) { var ak models.ApiKey if err := s.db.WithContext(ctx).Where("id = ?", id).First(&ak).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -378,10 +489,24 @@ func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey. } return nil, fmt.Errorf("failed to get API key: %w", err) } - if isStaticAPIKeyInternal(ak) { + if isStaticAPIKeyInternal(ak) || isEnvironmentBootstrapKeyInternal(ak) { return nil, ErrApiKeyProtected } + if req.Permissions != nil { + // Validate against the key owner's permissions, not the caller's, so a + // holder of apikeys:update cannot escalate another user's key beyond + // what that user's own roles allow. Owner-less keys (env bootstrap) + // fall back to the caller — static admin keys are rejected above. + ownerID := callerUserID + if ak.UserID != nil { + ownerID = *ak.UserID + } + if err := s.validateGrantsAgainstUserInternal(ctx, ownerID, req.Permissions); err != nil { + return nil, err + } + } + if req.Name != nil { ak.Name = *req.Name } @@ -392,8 +517,23 @@ func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey. ak.ExpiresAt = req.ExpiresAt } - if err := s.db.WithContext(ctx).Save(&ak).Error; err != nil { - return nil, fmt.Errorf("failed to update API key: %w", err) + permissionsUpdated := false + if err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Save(&ak).Error; err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } + if req.Permissions != nil && s.roleService != nil { + if err := s.roleService.setApiKeyPermissionsInternal(ctx, tx, ak.ID, toApiKeyPermissionRowsInternal(ak.ID, req.Permissions)); err != nil { + return fmt.Errorf("failed to update api key permissions: %w", err) + } + permissionsUpdated = true + } + return nil + }); err != nil { + return nil, err + } + if permissionsUpdated { + s.roleService.apiKeyCache.Delete(ak.ID) } return &apikey.ApiKey{ @@ -403,13 +543,37 @@ func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey. KeyPrefix: ak.KeyPrefix, UserID: ak.UserID, IsStatic: isStaticAPIKeyInternal(ak), + IsBootstrap: isEnvironmentBootstrapKeyInternal(ak), ExpiresAt: ak.ExpiresAt, LastUsedAt: ak.LastUsedAt, CreatedAt: ak.CreatedAt, UpdatedAt: ak.UpdatedAt, + Permissions: s.loadKeyGrantsInternal(ctx, ak.ID), }, nil } +// loadKeyGrantsInternal returns the persisted permission grants for an API key. +// Always returns a non-nil slice (possibly empty) so the JSON-marshalled DTO +// renders as `"permissions": []` rather than `"permissions": null`, which the +// frontend treats differently (null hides the picker, [] shows it empty). +// DB errors are logged and yield an empty slice — the caller never sees nil. +func (s *ApiKeyService) loadKeyGrantsInternal(ctx context.Context, apiKeyID string) []apikey.PermissionGrant { + empty := []apikey.PermissionGrant{} + if s.roleService == nil { + return empty + } + var rows []models.ApiKeyPermission + if err := s.db.WithContext(ctx).Where("api_key_id = ?", apiKeyID).Find(&rows).Error; err != nil { + slog.WarnContext(ctx, "failed to load api key permission grants", "api_key_id", apiKeyID, "error", err) + return empty + } + out := make([]apikey.PermissionGrant, len(rows)) + for i, r := range rows { + out[i] = apikey.PermissionGrant{Permission: r.Permission, EnvironmentID: r.EnvironmentID} + } + return out +} + func (s *ApiKeyService) DeleteApiKey(ctx context.Context, id string) error { var apiKey models.ApiKey if err := s.db.WithContext(ctx).Where("id = ?", id).First(&apiKey).Error; err != nil { @@ -418,7 +582,7 @@ func (s *ApiKeyService) DeleteApiKey(ctx context.Context, id string) error { } return fmt.Errorf("failed to load API key: %w", err) } - if isStaticAPIKeyInternal(apiKey) { + if isStaticAPIKeyInternal(apiKey) || isEnvironmentBootstrapKeyInternal(apiKey) { return ErrApiKeyProtected } @@ -433,39 +597,46 @@ func (s *ApiKeyService) DeleteApiKey(ctx context.Context, id string) error { } func (s *ApiKeyService) ValidateApiKey(ctx context.Context, rawKey string) (*models.User, error) { + user, _, err := s.ValidateApiKeyWithID(ctx, rawKey) + return user, err +} + +// ValidateApiKeyWithID is like ValidateApiKey but additionally returns the +// API key's database ID so callers can resolve per-key permissions. +func (s *ApiKeyService) ValidateApiKeyWithID(ctx context.Context, rawKey string) (*models.User, string, error) { keyPrefix, err := parseAPIKeyPrefixInternal(rawKey) if err != nil { - return nil, err + return nil, "", err } var apiKeys []models.ApiKey if err := s.db.WithContext(ctx).Where("key_prefix = ?", keyPrefix).Find(&apiKeys).Error; err != nil { - return nil, fmt.Errorf("failed to find API keys: %w", err) + return nil, "", fmt.Errorf("failed to find API keys: %w", err) } rawKey = normalizeAPIKeyInputInternal(rawKey) for _, apiKey := range apiKeys { if err := s.validateApiKeyHash(apiKey.KeyHash, rawKey); err == nil { if apiKey.ExpiresAt != nil && apiKey.ExpiresAt.Before(time.Now()) { - return nil, ErrApiKeyExpired + return nil, "", ErrApiKeyExpired } if apiKey.UserID == nil { - return nil, ErrApiKeyInvalid + return nil, "", ErrApiKeyInvalid } s.markApiKeyUsedAsync(ctx, apiKey.ID) user, err := s.userService.GetUserByID(ctx, *apiKey.UserID) if err != nil { - return nil, fmt.Errorf("failed to get user for API key: %w", err) + return nil, "", fmt.Errorf("failed to get user for API key: %w", err) } - return user, nil + return user, apiKey.ID, nil } } - return nil, ErrApiKeyInvalid + return nil, "", ErrApiKeyInvalid } func (s *ApiKeyService) GetEnvironmentByApiKey(ctx context.Context, rawKey string) (*string, error) { diff --git a/backend/internal/services/api_key_service_test.go b/backend/internal/services/api_key_service_test.go index f7b6cbf715..638f68de50 100644 --- a/backend/internal/services/api_key_service_test.go +++ b/backend/internal/services/api_key_service_test.go @@ -14,6 +14,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/apikey" ) @@ -47,7 +48,6 @@ func createTestAPIKeyUser(t *testing.T, ctx context.Context, userService *UserSe user := &models.User{ BaseModel: models.BaseModel{ID: id}, Username: fmt.Sprintf("user-%s", id), - Roles: models.StringSlice{"admin"}, } created, err := userService.CreateUser(ctx, user) @@ -118,7 +118,6 @@ func createDefaultAdminUser(t *testing.T, ctx context.Context, userService *User user := &models.User{ BaseModel: models.BaseModel{ID: "default-admin-user"}, Username: defaultAdminUsername, - Roles: models.StringSlice{"admin"}, } created, err := userService.CreateUser(ctx, user) @@ -191,7 +190,7 @@ func TestUpdateApiKeyRejectsStaticKey(t *testing.T) { created, err := service.CreateDefaultAdminAPIKey(ctx, adminUser.ID, "arc_bootstrapupdateprotected1234567890") require.NoError(t, err) - updated, err := service.UpdateApiKey(ctx, created.ApiKey.ID, apikey.UpdateApiKey{ + updated, err := service.UpdateApiKey(ctx, adminUser.ID, created.ApiKey.ID, apikey.UpdateApiKey{ Name: new("renamed"), Description: new("updated description"), }) @@ -205,6 +204,36 @@ func TestUpdateApiKeyRejectsStaticKey(t *testing.T) { require.Equal(t, *defaultAdminAPIKeyDescription, *apiKeys[0].Description) } +func TestUpdateApiKeyRollsBackMetadataWhenPermissionUpdateFails(t *testing.T) { + ctx := context.Background() + db := setupAuthServiceTestDB(t) + require.NoError(t, db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, ''))").Error) + + roleSvc := NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(ctx)) + userSvc := NewUserService(db).WithRoleService(roleSvc) + service := NewApiKeyService(db, userSvc).WithRoleService(roleSvc) + admin := createTestUser(t, userSvc, "admin-update-rollback", "admin-update-rollback") + grantGlobalAdmin(t, roleSvc, admin.ID) + + created, err := service.CreateApiKey(ctx, admin.ID, apikey.CreateApiKey{Name: "original"}) + require.NoError(t, err) + + updatedName := "renamed" + updated, err := service.UpdateApiKey(ctx, admin.ID, created.ApiKey.ID, apikey.UpdateApiKey{ + Name: &updatedName, + Permissions: []apikey.PermissionGrant{ + {Permission: authz.PermContainersList}, + {Permission: authz.PermContainersList}, + }, + }) + require.Nil(t, updated) + require.Error(t, err) + + stored := fetchAPIKey(t, db, created.ApiKey.ID) + require.Equal(t, "original", stored.Name) +} + func TestReconcileDefaultAdminAPIKeyNoOpWhenUnchanged(t *testing.T) { ctx := context.Background() service, db, userService := setupAPIKeyService(t) @@ -407,6 +436,66 @@ func TestGetEnvironmentByAPIKeyExpiredDoesNotUpdateLastUsedAt(t *testing.T) { require.Nil(t, apiKey.LastUsedAt) } +func TestCreateEnvironmentApiKeySeedsAllPermissionsScopedToEnv(t *testing.T) { + ctx := context.Background() + db := setupAuthServiceTestDB(t) + require.NoError(t, db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, ''))").Error) + + roleSvc := NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(ctx)) + userSvc := NewUserService(db).WithRoleService(roleSvc) + service := NewApiKeyService(db, userSvc).WithRoleService(roleSvc) + admin := createTestUser(t, userSvc, "admin-env-bootstrap", "admin-env-bootstrap") + grantGlobalAdmin(t, roleSvc, admin.ID) + + envID := "env-bootstrap-test" + created, err := service.CreateEnvironmentApiKey(ctx, envID, admin.ID) + require.NoError(t, err) + + // Resolve the per-key permission set and confirm every permission is + // present, scoped to the bootstrap env (not global). + ps, err := roleSvc.ResolveApiKeyPermissions(ctx, created.ApiKey.ID) + require.NoError(t, err) + require.Empty(t, ps.Global, "bootstrap key permissions must land in PerEnv, not Global") + envPerms, ok := ps.PerEnv[envID] + require.True(t, ok) + for _, p := range authz.AllPermissions() { + _, has := envPerms[p] + require.True(t, has, "missing permission %s on bootstrap key", p) + } +} + +func TestBackfillApiKeyPermissionsRepairsExistingBootstrapKey(t *testing.T) { + ctx := context.Background() + db := setupAuthServiceTestDB(t) + require.NoError(t, db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, ''))").Error) + + roleSvc := NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(ctx)) + + // Simulate a pre-existing env-bootstrap key with NO permission grants + // (e.g., created on a deployment where the per-key seed step failed). + envID := "env-broken-bootstrap" + require.NoError(t, db.WithContext(ctx).Create(&models.ApiKey{ + Name: "Environment Bootstrap Key - broken", + KeyHash: "hash", + KeyPrefix: "arc_brkn", + EnvironmentID: &envID, + }).Error) + + require.NoError(t, roleSvc.BackfillApiKeyPermissions(ctx)) + + // The backfill should have populated the env-scoped perms retroactively. + var keys []models.ApiKey + require.NoError(t, db.WithContext(ctx).Where("environment_id = ?", envID).Find(&keys).Error) + require.Len(t, keys, 1) + ps, err := roleSvc.ResolveApiKeyPermissions(ctx, keys[0].ID) + require.NoError(t, err) + envPerms, ok := ps.PerEnv[envID] + require.True(t, ok) + require.Equal(t, len(authz.AllPermissions()), len(envPerms)) +} + func TestGetEnvironmentByAPIKeyRecentLastUsedAtDoesNotRewriteImmediately(t *testing.T) { ctx := context.Background() service, db, userService := setupAPIKeyService(t) diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 2752106d8b..2015f18713 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -45,13 +45,12 @@ type AuthSettings struct { type userClaims struct { jwt.RegisteredClaims - SessionID string `json:"sid,omitempty"` - UserID string `json:"user_id"` - Username string `json:"username"` - Email string `json:"email,omitempty"` - DisplayName string `json:"display_name,omitempty"` - Roles []string `json:"roles"` - AppVersion string `json:"app_version,omitempty"` + SessionID string `json:"sid,omitempty"` + UserID string `json:"user_id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + AppVersion string `json:"app_version,omitempty"` } type refreshClaims struct { @@ -71,6 +70,7 @@ type AuthService struct { settingsService *SettingsService eventService *EventService sessionService *SessionService + roleService *RoleService jwtSecret []byte refreshExpiry time.Duration config *config.Config @@ -81,12 +81,13 @@ type AuthService struct { tokenCache *cache.TTL[verifiedTokenEntry] } -func NewAuthService(userService *UserService, settingsService *SettingsService, eventService *EventService, sessionService *SessionService, jwtSecret string, cfg *config.Config) *AuthService { +func NewAuthService(userService *UserService, settingsService *SettingsService, eventService *EventService, sessionService *SessionService, roleService *RoleService, jwtSecret string, cfg *config.Config) *AuthService { return &AuthService{ userService: userService, settingsService: settingsService, eventService: eventService, sessionService: sessionService, + roleService: roleService, jwtSecret: jwtclaims.CheckOrGenerateJwtSecret(jwtSecret), refreshExpiry: cfg.JWTRefreshExpiry, config: cfg, @@ -119,8 +120,7 @@ func (s *AuthService) getAuthSettings(ctx context.Context) (*AuthSettings, error JwksURI: settings.OidcJwksEndpoint.Value, DeviceAuthorizationEndpoint: settings.OidcDeviceAuthorizationEndpoint.Value, Scopes: settings.OidcScopes.Value, - AdminClaim: settings.OidcAdminClaim.Value, - AdminValue: settings.OidcAdminValue.Value, + GroupsClaim: settings.OidcGroupsClaim.Value, SkipTlsVerify: settings.OidcSkipTlsVerify.IsTrue(), } @@ -419,11 +419,6 @@ func (s *AuthService) createOidcUser(ctx context.Context, userInfo auth.OidcUser username = userInfo.PreferredUsername } - roles := models.StringSlice{"user"} - if s.isAdminFromOidc(ctx, userInfo, tokenResp) { - roles = append(roles, "admin") - } - var displayName *string switch { case userInfo.Name != "": @@ -439,7 +434,6 @@ func (s *AuthService) createOidcUser(ctx context.Context, userInfo auth.OidcUser Username: username, DisplayName: displayName, Email: new(userInfo.Email), - Roles: roles, OidcSubjectId: new(userInfo.Subject), LastLogin: new(time.Now()), } @@ -449,6 +443,9 @@ func (s *AuthService) createOidcUser(ctx context.Context, userInfo auth.OidcUser if _, err := s.userService.CreateUser(ctx, user); err != nil { return nil, err } + if err := s.syncOidcRoleAssignments(ctx, user, userInfo, tokenResp); err != nil { + slog.WarnContext(ctx, "failed to sync OIDC role assignments on user create", "error", err, "user_id", user.ID) + } return user, nil } @@ -460,116 +457,143 @@ func (s *AuthService) updateOidcUser(ctx context.Context, user *models.User, use user.Email = new(userInfo.Email) } - wantAdmin := s.isAdminFromOidc(ctx, userInfo, tokenResp) - hasAdmin := hasRole(user.Roles, "admin") - switch { - case wantAdmin && !hasAdmin: - user.Roles = addRole(user.Roles, "admin") - case !wantAdmin && hasAdmin: - user.Roles = removeRole(user.Roles, "admin") - } - s.persistOidcTokens(user, tokenResp) user.LastLogin = new(time.Now()) - _, err := s.userService.UpdateUser(ctx, user) - return err + if _, err := s.userService.UpdateUser(ctx, user); err != nil { + return err + } + if err := s.syncOidcRoleAssignments(ctx, user, userInfo, tokenResp); err != nil { + slog.WarnContext(ctx, "failed to sync OIDC role assignments on user update", "error", err, "user_id", user.ID) + } + return nil } func (s *AuthService) mergeOidcWithExistingUser(ctx context.Context, user *models.User, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) error { // Perform the merge atomically to avoid races when multiple OIDC subjects share the same email - _, err := s.userService.AttachOidcSubjectTransactional(ctx, user.ID, userInfo.Subject, func(u *models.User) { - // Update display name if not set + merged, err := s.userService.AttachOidcSubjectTransactional(ctx, user.ID, userInfo.Subject, func(u *models.User) { if userInfo.Name != "" && u.DisplayName == nil { u.DisplayName = new(userInfo.Name) } - - // Update admin role based on OIDC claims - wantAdmin := s.isAdminFromOidc(ctx, userInfo, tokenResp) - hasAdmin := hasRole(u.Roles, "admin") - switch { - case wantAdmin && !hasAdmin: - u.Roles = addRole(u.Roles, "admin") - case !wantAdmin && hasAdmin: - u.Roles = removeRole(u.Roles, "admin") - } - - // Persist OIDC tokens s.persistOidcTokens(u, tokenResp) - u.LastLogin = new(time.Now()) }) - return err -} - -func hasRole(roles models.StringSlice, role string) bool { - for _, r := range roles { - if strings.EqualFold(r, role) { - return true + if err != nil { + return err + } + if merged != nil { + if syncErr := s.syncOidcRoleAssignments(ctx, merged, userInfo, tokenResp); syncErr != nil { + slog.WarnContext(ctx, "failed to sync OIDC role assignments on user merge", "error", syncErr, "user_id", merged.ID) } } - return false + return nil } -func addRole(roles models.StringSlice, role string) models.StringSlice { - if hasRole(roles, role) { - return roles +// syncOidcRoleAssignments rebuilds the user's `source='oidc'` role assignments +// based on the OIDC group claim and the configured OidcRoleMapping rows. +// Manual assignments are untouched. +func (s *AuthService) syncOidcRoleAssignments(ctx context.Context, user *models.User, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) error { + if s.roleService == nil || user == nil { + return nil } - return append(roles, role) -} -func removeRole(roles models.StringSlice, role string) models.StringSlice { - out := make(models.StringSlice, 0, len(roles)) - for _, r := range roles { - if !strings.EqualFold(r, role) { - out = append(out, r) - } + groups := s.extractOidcGroups(ctx, userInfo, tokenResp) + mappings, err := s.roleService.ListOidcMappings(ctx) + if err != nil { + return fmt.Errorf("list oidc mappings: %w", err) } - return out -} -func (s *AuthService) isAdminFromOidc(ctx context.Context, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) bool { - claimKey, values := s.getAdminClaimConfig(ctx) - if claimKey == "" { - return false + groupSet := make(map[string]struct{}, len(groups)) + for _, g := range groups { + groupSet[g] = struct{}{} } - if v, ok := jwtclaims.GetByPath(userInfo.Extra, claimKey); ok && jwtclaims.EvalMatch(v, values) { - return true + var desired []models.UserRoleAssignment + seen := make(map[string]struct{}) // dedup by roleID|envID + for _, m := range mappings { + if _, ok := groupSet[m.ClaimValue]; !ok { + continue + } + key := m.RoleID + "|" + if m.EnvironmentID != nil { + key += *m.EnvironmentID + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + desired = append(desired, models.UserRoleAssignment{ + RoleID: m.RoleID, + EnvironmentID: m.EnvironmentID, + }) } - if tokenResp != nil && tokenResp.IDToken != "" { - if claims := jwtclaims.ParseJWTClaims(tokenResp.IDToken); claims != nil { - if v, ok := jwtclaims.GetByPath(claims, claimKey); ok && jwtclaims.EvalMatch(v, values) { - return true + return s.roleService.ReplaceOidcAssignments(ctx, user.ID, desired) +} + +// extractOidcGroups reads the user's group memberships from the OIDC userinfo +// and ID token, using the claim path configured in OidcGroupsClaim (defaults +// to "groups"). Falls back to userInfo.Groups if no value is found at the +// configured path. +func (s *AuthService) extractOidcGroups(ctx context.Context, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) []string { + claim := s.oidcGroupsClaim(ctx) + + if claim != "" { + if v, ok := jwtclaims.GetByPath(userInfo.Extra, claim); ok { + if groups := stringValuesFromClaim(v); len(groups) > 0 { + return groups + } + } + if tokenResp != nil && tokenResp.IDToken != "" { + if parsed := jwtclaims.ParseJWTClaims(tokenResp.IDToken); parsed != nil { + if v, ok := jwtclaims.GetByPath(parsed, claim); ok { + if groups := stringValuesFromClaim(v); len(groups) > 0 { + return groups + } + } } } } - return false + return userInfo.Groups } -func (s *AuthService) getAdminClaimConfig(ctx context.Context) (claim string, values []string) { - as, err := s.getAuthSettings(ctx) - if err != nil || as.Oidc == nil { - return "", nil - } - claim = strings.TrimSpace(as.Oidc.AdminClaim) - raw := strings.TrimSpace(as.Oidc.AdminValue) - if claim == "" { - return "", nil +func (s *AuthService) oidcGroupsClaim(ctx context.Context) string { + settings, err := s.settingsService.GetSettings(ctx) + if err != nil { + return "groups" } - if raw == "" { - return claim, nil + v := strings.TrimSpace(settings.OidcGroupsClaim.Value) + if v == "" { + return "groups" } - parts := strings.SplitSeq(raw, ",") - for p := range parts { - v := strings.TrimSpace(p) - if v != "" { - values = append(values, v) + return v +} + +// stringValuesFromClaim flattens a claim value into a slice of strings. +// Accepts string, []string, []any (coerces each element to string), or nil. +func stringValuesFromClaim(v any) []string { + switch typed := v.(type) { + case nil: + return nil + case string: + if typed == "" { + return nil + } + return []string{typed} + case []string: + return typed + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } } + return out + default: + return nil } - return claim, values } func (s *AuthService) persistOidcTokens(user *models.User, tokenResp *auth.OidcTokenResponse) { @@ -813,7 +837,6 @@ func (s *AuthService) buildTokenPairInternal(ctx context.Context, user *models.U SessionID: session.ID, UserID: user.ID, Username: user.Username, - Roles: []string(user.Roles), AppVersion: config.Version, } diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index 18dbcc8ec7..d4c88af154 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -24,7 +24,17 @@ func setupAuthServiceTestDB(t *testing.T) *database.DB { t.Helper() db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.SettingVariable{}, &models.User{}, &models.UserSession{})) + require.NoError(t, db.AutoMigrate( + &models.SettingVariable{}, + &models.User{}, + &models.UserSession{}, + &models.Environment{}, + &models.Role{}, + &models.UserRoleAssignment{}, + &models.ApiKey{}, + &models.ApiKeyPermission{}, + &models.OidcRoleMapping{}, + )) return &database.DB{DB: db} } @@ -49,7 +59,7 @@ func newTestAuthService(secret string) *AuthService { } } -func makeAccessToken(t *testing.T, secret []byte, subject string, id string, username string, roles []string, email, displayName string, exp time.Time, sessionIDs ...string) string { +func makeAccessToken(t *testing.T, secret []byte, subject string, id string, username string, _ []string, email, displayName string, exp time.Time, sessionIDs ...string) string { t.Helper() sessionID := "" if len(sessionIDs) > 0 { @@ -65,7 +75,6 @@ func makeAccessToken(t *testing.T, secret []byte, subject string, id string, use SessionID: sessionID, UserID: id, Username: username, - Roles: roles, Email: email, DisplayName: displayName, AppVersion: config.Version, @@ -141,7 +150,6 @@ func TestVerifyToken_ValidClaims(t *testing.T) { Username: "alice", Email: new("a@example.com"), DisplayName: new("Alice"), - Roles: models.StringSlice{"user", "admin"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -160,9 +168,6 @@ func TestVerifyToken_ValidClaims(t *testing.T) { if verifiedUser.Username != "alice" { t.Errorf("username %q", verifiedUser.Username) } - if len(verifiedUser.Roles) != 2 || verifiedUser.Roles[0] != "user" || verifiedUser.Roles[1] != "admin" { - t.Errorf("roles %v", verifiedUser.Roles) - } if verifiedUser.Email == nil || *verifiedUser.Email != "a@example.com" { t.Errorf("email %v", verifiedUser.Email) } @@ -183,7 +188,6 @@ func TestVerifyToken_RejectsNonHMACAlg(t *testing.T) { }, UserID: "u1", Username: "bob", - Roles: []string{"user"}, AppVersion: config.Version, }) @@ -204,7 +208,6 @@ func TestVerifyToken_Expired(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u1"}, Username: "bob", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -325,7 +328,6 @@ func TestRefreshToken_Valid(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-refresh"}, Username: "refresh-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -357,7 +359,6 @@ func TestRefreshToken_VersionMismatchRotates(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-versionmismatch"}, Username: "versionmismatch-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -404,7 +405,6 @@ func TestVerifyToken_RejectsRevokedSession(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-revoked"}, Username: "revoked-user", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -428,7 +428,6 @@ func TestVerifyToken_RejectsMissingSessionID(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-no-sid"}, Username: "no-sid-user", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -449,7 +448,6 @@ func TestRevokeSessionThenVerifyTokenFails(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-logout"}, Username: "logout-user", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -476,7 +474,6 @@ func TestRefreshToken_RotatesJTI(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-rotate"}, Username: "rotate-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -506,7 +503,6 @@ func TestRefreshToken_RejectsRevokedSession(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-refresh-revoked"}, Username: "refresh-revoked-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -533,7 +529,6 @@ func TestChangePassword_RevokesAllSessions(t *testing.T) { BaseModel: models.BaseModel{ID: "u-password"}, Username: "password-user", PasswordHash: passwordHash, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -564,7 +559,6 @@ func TestChangePassword_KeepsCurrentSessionAlive(t *testing.T) { BaseModel: models.BaseModel{ID: "u-keep"}, Username: "keep-user", PasswordHash: passwordHash, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -706,7 +700,6 @@ func TestFindOrCreateOidcUser_MergeEnabled_EmailNotVerified_WithExistingUser_Ret BaseModel: models.BaseModel{ID: "u1"}, Username: "existing", Email: &email, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(ctx, existing) require.NoError(t, err) @@ -749,7 +742,6 @@ func TestFindOrCreateOidcUser_MergeEnabled_EmailVerificationMissing_WithExisting BaseModel: models.BaseModel{ID: "u1"}, Username: "existing", Email: &email, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(ctx, existing) require.NoError(t, err) diff --git a/backend/internal/services/environment_service_test.go b/backend/internal/services/environment_service_test.go index 0c7d3ac1c9..b9c1f650ca 100644 --- a/backend/internal/services/environment_service_test.go +++ b/backend/internal/services/environment_service_test.go @@ -67,7 +67,6 @@ func createTestEnvironmentServiceUser(t *testing.T, ctx context.Context, userSer user := &models.User{ BaseModel: models.BaseModel{ID: id}, Username: fmt.Sprintf("user-%s", id), - Roles: models.StringSlice{"admin"}, } created, err := userService.CreateUser(ctx, user) diff --git a/backend/internal/services/playwright_service.go b/backend/internal/services/playwright_service.go index 64bac28b43..d3ea9553d2 100644 --- a/backend/internal/services/playwright_service.go +++ b/backend/internal/services/playwright_service.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/apikey" ) @@ -32,11 +33,23 @@ func (ps *PlaywrightService) CreateTestApiKeys(ctx context.Context, count int) ( return nil, fmt.Errorf("failed to get arcane user: %w", err) } + // Grant every recognized permission globally so the test key behaves like + // the legacy "admin-everywhere" credential the e2e suite expects. The + // owner is the `arcane` bootstrap user, who holds global Admin and + // therefore satisfies validateGrantsAgainstUserInternal. + allPerms := authz.AllPermissions() + grants := make([]apikey.PermissionGrant, len(allPerms)) + for i, p := range allPerms { + grants[i] = apikey.PermissionGrant{Permission: p} + } + var createdKeys []*apikey.ApiKeyCreatedDto for i := 0; i < count; i++ { + description := fmt.Sprintf("Test API key %d for Playwright tests", i+1) req := apikey.CreateApiKey{ Name: fmt.Sprintf("test-api-key-%d", i+1), - Description: new(fmt.Sprintf("Test API key %d for Playwright tests", i+1)), + Description: &description, + Permissions: grants, } apiKey, err := ps.apiKeyService.CreateApiKey(ctx, user.ID, req) diff --git a/backend/internal/services/role_service.go b/backend/internal/services/role_service.go new file mode 100644 index 0000000000..a3adf8d7bb --- /dev/null +++ b/backend/internal/services/role_service.go @@ -0,0 +1,932 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils/cache" + "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" + roletypes "github.com/getarcaneapp/arcane/types/role" +) + +// permissionCacheTTL bounds how long a resolved PermissionSet is reused +// before re-querying the DB. The service also invalidates entries explicitly +// on mutation paths, so this TTL is a safety net. +const permissionCacheTTL = 60 * time.Second + +// RoleService owns role definitions, user role assignments, OIDC role +// mappings, and API key permissions. It resolves a caller's effective +// PermissionSet on demand and caches the result per-user / per-key for a +// short TTL to keep the hot path off the database. +type RoleService struct { + db *database.DB + userCache *cache.TTL[*authz.PermissionSet] + apiKeyCache *cache.TTL[*authz.PermissionSet] +} + +func NewRoleService(db *database.DB) *RoleService { + return &RoleService{ + db: db, + userCache: cache.NewTTL[*authz.PermissionSet](permissionCacheTTL), + apiKeyCache: cache.NewTTL[*authz.PermissionSet](permissionCacheTTL), + } +} + +// ---------- Boot-time reconciliation & safety checks ---------- + +// EnsureBuiltInRoles overwrites the permission set on every built-in role to +// match the Go constants. Idempotent. Called at boot after migrations succeed. +func (s *RoleService) EnsureBuiltInRoles(ctx context.Context) error { + builtIns := map[string]struct { + name string + desc string + perm []string + }{ + authz.BuiltInRoleAdmin: {"Admin", "Full administrative access", authz.AllPermissions()}, + authz.BuiltInRoleEditor: {"Editor", "Read and write on Docker resources", authz.BuiltInEditorPermissions()}, + authz.BuiltInRoleNoShellEditor: {"No-Shell Editor", "Editor without interactive container shell access", authz.BuiltInNoShellEditorPermissions()}, + authz.BuiltInRoleDeployer: {"Deployer", "Deploy and lifecycle containers and projects", authz.BuiltInDeployerPermissions()}, + authz.BuiltInRoleMonitor: {"Monitor", "Observability-only access: logs, dashboards, events", authz.BuiltInMonitorPermissions()}, + authz.BuiltInRoleViewer: {"Viewer", "Read-only access to all resources", authz.BuiltInViewerPermissions()}, + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + for id, spec := range builtIns { + desc := spec.desc + role := models.Role{ + BaseModel: models.BaseModel{ID: id}, + Name: spec.name, + Description: &desc, + Permissions: models.StringSlice(spec.perm), + BuiltIn: true, + } + if err := tx.Save(&role).Error; err != nil { + return fmt.Errorf("failed to upsert built-in role %s: %w", id, err) + } + } + return nil + }) +} + +// BackfillLegacyRoleAssignments migrates the pre-RBAC users.roles JSON column +// into rows in user_role_assignments. Safe to call on every boot: a no-op once +// the column is gone. +// +// Users with "admin" in their legacy roles get a global Admin assignment; +// every other user gets a global Viewer assignment. The NULL environment_id +// lands the perms in PermissionSet.Global, which is what ps.Allows(perm, "") +// consults for org-level checks (list environments, read settings, list users, +// etc.) AND for env-scoped checks at the union step. Inserting per-environment +// viewer rows instead would lock non-admins out of the settings area entirely. +// +// Lives here (not as a SQL migration) so the column-existence check is trivial +// in Go and the same code path covers both postgres and sqlite. Idempotent via +// ON CONFLICT DO NOTHING on the (user_id, role_id, env) unique index, so a +// half-finished prior run can be safely retried. +func (s *RoleService) BackfillLegacyRoleAssignments(ctx context.Context) error { + migrator := s.db.WithContext(ctx).Migrator() + if !migrator.HasColumn("users", "roles") { + return nil + } + + type legacyUser struct { + ID string `gorm:"column:id"` + Roles string `gorm:"column:roles"` + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var rows []legacyUser + if err := tx.Table("users").Select("id, roles").Scan(&rows).Error; err != nil { + return fmt.Errorf("failed to read legacy users.roles for backfill: %w", err) + } + for _, u := range rows { + roleID := authz.BuiltInRoleViewer + if legacyRolesContainsAdminInternal(u.Roles) { + roleID = authz.BuiltInRoleAdmin + } + assignment := models.UserRoleAssignment{ + UserID: u.ID, + RoleID: roleID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to backfill assignment for user %s: %w", u.ID, err) + } + } + slog.InfoContext(ctx, "Backfilled legacy users.roles into user_role_assignments", "userCount", len(rows)) + return nil + }) +} + +// legacyRolesContainsAdminInternal reports whether a pre-RBAC users.roles JSON +// value contains the literal "admin" (case-insensitive). Empty / null / malformed +// JSON yields false — treat as non-admin and assign Viewer. +func legacyRolesContainsAdminInternal(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" || raw == "null" { + return false + } + var roles []string + if err := json.Unmarshal([]byte(raw), &roles); err != nil { + return false + } + for _, r := range roles { + if strings.EqualFold(strings.TrimSpace(r), "admin") { + return true + } + } + return false +} + +// AssertGlobalAdminExists returns a *common.NoGlobalAdminRemainsError if zero +// users hold a globally-scoped Admin role assignment. Called at boot after the +// backfill migration; also called from inside mutation paths. +func (s *RoleService) AssertGlobalAdminExists(ctx context.Context) error { + count, err := s.countGlobalAdminsInternal(ctx, s.db.WithContext(ctx)) + if err != nil { + return err + } + if count == 0 { + return &common.NoGlobalAdminRemainsError{} + } + return nil +} + +// BackfillApiKeyPermissions populates api_key_permissions for every existing +// API key whose row has no permissions yet. Each key inherits a snapshot of +// its owner's current effective permissions (scoped per the key's +// environment_id when set). Idempotent: skips if the table is non-empty. +// BackfillApiKeyPermissions ensures every ownerless (bootstrap) API key has +// its expected permission grants. Called once per boot. +// +// Per-key, not all-or-nothing: a single bootstrap key with zero grants is +// repaired even if other keys are already populated. This recovers env- +// bootstrap keys that pre-date the per-key permission feature, or that were +// created on a deployment where the original SetApiKeyPermissions call failed +// (e.g., the api_key_permissions table didn't exist yet). +// +// User-owned keys are deliberately skipped. A user-owned key with zero grants +// is an intentional "no access" state; rehydrating from the owner's effective +// permissions on every boot would clobber that. User keys are seeded at +// creation time by CreateApiKey instead. +func (s *RoleService) BackfillApiKeyPermissions(ctx context.Context) error { + // Bootstrap-class keys we'll repair if they have zero grants: + // - user_id IS NULL → env-bootstrap keys. + // - managed_by IS NOT NULL → admin-static (ADMIN_STATIC_API_KEY) keys, + // which DO have a user_id but are infrastructure-managed and must + // always carry full perms. + // Regular user-created keys are deliberately excluded — empty grants on + // those are an intentional "no access" state we must not overwrite. + var keys []models.ApiKey + if err := s.db.WithContext(ctx).Where("user_id IS NULL OR managed_by IS NOT NULL").Find(&keys).Error; err != nil { + return fmt.Errorf("failed to list bootstrap api keys for backfill: %w", err) + } + if len(keys) == 0 { + return nil + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + for _, key := range keys { + var existing int64 + if err := tx.Model(&models.ApiKeyPermission{}).Where("api_key_id = ?", key.ID).Count(&existing).Error; err != nil { + return fmt.Errorf("failed to count permissions for api key %s: %w", key.ID, err) + } + if existing > 0 { + continue + } + perms, err := s.backfillPermsForKeyInternal(ctx, tx, key) + if err != nil { + return err + } + for _, p := range perms { + if err := tx.Create(&models.ApiKeyPermission{ + ApiKeyID: key.ID, + Permission: p, + EnvironmentID: key.EnvironmentID, + }).Error; err != nil { + return fmt.Errorf("failed to seed api key permission: %w", err) + } + } + slog.InfoContext(ctx, "Backfilled missing permissions for bootstrap api key", "api_key_id", key.ID, "perm_count", len(perms), "env_id", key.EnvironmentID) + } + return nil + }) +} + +func (s *RoleService) backfillPermsForKeyInternal(ctx context.Context, tx *gorm.DB, key models.ApiKey) ([]string, error) { + // Static admin bootstrap keys (no owner, no env scope) get full access. + if key.UserID == nil && key.EnvironmentID == nil { + return authz.AllPermissions(), nil + } + // Environment bootstrap keys (key bound to a specific env, no owner) get + // full access scoped to that environment via the auth bridge — replicate + // that by granting all permissions scoped to that environment. Org-level + // permissions land in PerEnv[envID] and are unreachable via org-level + // checks (which always pass envID=""), so over-granting is harmless here. + if key.UserID == nil && key.EnvironmentID != nil { + return authz.AllPermissions(), nil + } + // Otherwise inherit the owner's current effective permissions. + var owner models.User + if err := tx.WithContext(ctx).Where("id = ?", *key.UserID).First(&owner).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to load api key owner: %w", err) + } + ps, err := s.resolveUserPermissionsInternal(ctx, tx, owner.ID) + if err != nil { + return nil, err + } + // Flatten ps into a deduplicated list of permissions for this key's scope. + seen := make(map[string]struct{}, len(ps.Global)) + for p := range ps.Global { + seen[p] = struct{}{} + } + if key.EnvironmentID != nil { + if env, ok := ps.PerEnv[*key.EnvironmentID]; ok { + for p := range env { + seen[p] = struct{}{} + } + } + } + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + return out, nil +} + +// ---------- Role CRUD ---------- + +func (s *RoleService) ListRoles(ctx context.Context, params pagination.QueryParams) ([]models.Role, pagination.Response, error) { + var roles []models.Role + query := s.db.WithContext(ctx).Model(&models.Role{}) + + if term := strings.TrimSpace(params.Search); term != "" { + pattern := "%" + term + "%" + query = query.Where("name LIKE ? OR COALESCE(description, '') LIKE ?", pattern, pattern) + } + + resp, err := pagination.PaginateAndSortDB(params, query, &roles) + if err != nil { + return nil, pagination.Response{}, fmt.Errorf("failed to paginate roles: %w", err) + } + return roles, resp, nil +} + +func (s *RoleService) ListAllRoles(ctx context.Context) ([]models.Role, error) { + var roles []models.Role + if err := s.db.WithContext(ctx).Order("name").Find(&roles).Error; err != nil { + return nil, fmt.Errorf("failed to list roles: %w", err) + } + return roles, nil +} + +func (s *RoleService) GetRole(ctx context.Context, id string) (*models.Role, error) { + return dbutil.FirstWhere[models.Role](ctx, s.db.DB, &common.RoleNotFoundError{}, "id = ?", id) +} + +func (s *RoleService) CreateRole(ctx context.Context, name string, description *string, permissions []string) (*models.Role, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("role name is required") + } + if err := validatePermissionsInternal(permissions); err != nil { + return nil, err + } + role := &models.Role{ + Name: name, + Description: description, + Permissions: models.StringSlice(permissions), + BuiltIn: false, + } + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var conflict int64 + if err := tx.Model(&models.Role{}).Where("name = ?", name).Count(&conflict).Error; err != nil { + return fmt.Errorf("failed to check role name uniqueness: %w", err) + } + if conflict > 0 { + return &common.RoleNameTakenError{} + } + return tx.Create(role).Error + }) + if err != nil { + return nil, err + } + return role, nil +} + +func (s *RoleService) UpdateRole(ctx context.Context, id, name string, description *string, permissions []string) (*models.Role, error) { + if err := validatePermissionsInternal(permissions); err != nil { + return nil, err + } + var out models.Role + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.Role + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.RoleNotFoundError{} + } + return fmt.Errorf("failed to load role: %w", err) + } + if existing.BuiltIn { + return &common.RoleBuiltInError{} + } + if name != existing.Name { + var conflict int64 + if err := tx.Model(&models.Role{}).Where("name = ? AND id <> ?", name, id).Count(&conflict).Error; err != nil { + return fmt.Errorf("failed to check role name uniqueness: %w", err) + } + if conflict > 0 { + return &common.RoleNameTakenError{} + } + } + existing.Name = name + existing.Description = description + existing.Permissions = models.StringSlice(permissions) + if err := tx.Save(&existing).Error; err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + out = existing + return nil + }) + if err != nil { + return nil, err + } + s.invalidateUsersAssignedToInternal(ctx, id) + return &out, nil +} + +func (s *RoleService) DeleteRole(ctx context.Context, id string) error { + var affected []string + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.Role + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.RoleNotFoundError{} + } + return fmt.Errorf("failed to load role: %w", err) + } + if existing.BuiltIn { + return &common.RoleBuiltInError{} + } + // Collect users affected before the delete so we can invalidate their caches. + if err := tx.Model(&models.UserRoleAssignment{}). + Where("role_id = ?", id). + Distinct("user_id"). + Pluck("user_id", &affected).Error; err != nil { + return fmt.Errorf("failed to list affected users: %w", err) + } + if err := tx.Delete(&models.Role{}, "id = ?", id).Error; err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + return nil + }) + if err != nil { + return err + } + // Invalidate caches AFTER the transaction commits so a concurrent + // cache-miss cannot re-populate with stale data from the not-yet-visible + // delete. Consistent with UpdateRole / SetUserAssignments. + for _, uid := range affected { + s.userCache.Delete(uid) + } + return nil +} + +// CountUsersAssignedToRole returns how many distinct users hold an assignment +// to the given role (any source, any environment scope). +func (s *RoleService) CountUsersAssignedToRole(ctx context.Context, roleID string) (int, error) { + var count int64 + if err := s.db.WithContext(ctx). + Model(&models.UserRoleAssignment{}). + Distinct("user_id"). + Where("role_id = ?", roleID). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count users assigned to role: %w", err) + } + return int(count), nil +} + +// ---------- User role assignments ---------- + +func (s *RoleService) ListUserAssignments(ctx context.Context, userID string) ([]models.UserRoleAssignment, error) { + var out []models.UserRoleAssignment + if err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("source ASC, role_id ASC"). + Find(&out).Error; err != nil { + return nil, fmt.Errorf("failed to list user assignments: %w", err) + } + return out, nil +} + +// SetUserAssignments replaces the user's source='manual' assignments with the +// given desired set. Source='oidc' rows are preserved (use +// ReplaceOidcAssignments for those). Enforces the global-admin guard. +func (s *RoleService) SetUserAssignments(ctx context.Context, userID string, desired []models.UserRoleAssignment) error { + for i := range desired { + desired[i].UserID = userID + desired[i].Source = models.RoleAssignmentSourceManual + } + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + // Validate inside the tx so a concurrent role/env delete can't race past + // this check. Yields a typed error → 400 at the handler, rather than an + // opaque FK violation surfacing as 500. + if err := validateAssignmentsExistInternal(tx, desired); err != nil { + return err + } + if err := tx.Where("user_id = ? AND source = ?", userID, models.RoleAssignmentSourceManual). + Delete(&models.UserRoleAssignment{}).Error; err != nil { + return fmt.Errorf("failed to clear manual assignments: %w", err) + } + if len(desired) > 0 { + if err := tx.Create(&desired).Error; err != nil { + return fmt.Errorf("failed to insert assignments: %w", err) + } + } + count, err := s.countGlobalAdminsInternal(ctx, tx) + if err != nil { + return err + } + if count == 0 { + return &common.NoGlobalAdminRemainsError{} + } + return nil + }) + if err != nil { + return err + } + s.userCache.Delete(userID) + return nil +} + +// validateAssignmentsExistInternal verifies every distinct RoleID and +// EnvironmentID referenced by `desired` exists in the database. Returns the +// first missing reference wrapped in an InvalidRoleAssignmentError so the +// handler can map it to a 400 with a descriptive message. +func validateAssignmentsExistInternal(tx *gorm.DB, desired []models.UserRoleAssignment) error { + roleIDSet := make(map[string]struct{}, len(desired)) + envIDSet := make(map[string]struct{}, len(desired)) + for _, a := range desired { + roleIDSet[a.RoleID] = struct{}{} + if a.EnvironmentID != nil { + envIDSet[*a.EnvironmentID] = struct{}{} + } + } + + if len(roleIDSet) > 0 { + roleIDs := make([]string, 0, len(roleIDSet)) + for id := range roleIDSet { + roleIDs = append(roleIDs, id) + } + var found []string + if err := tx.Model(&models.Role{}).Where("id IN ?", roleIDs).Pluck("id", &found).Error; err != nil { + return fmt.Errorf("failed to verify role ids: %w", err) + } + foundSet := make(map[string]struct{}, len(found)) + for _, id := range found { + foundSet[id] = struct{}{} + } + for id := range roleIDSet { + if _, ok := foundSet[id]; !ok { + return &common.InvalidRoleAssignmentError{RoleID: id} + } + } + } + + if len(envIDSet) > 0 { + envIDs := make([]string, 0, len(envIDSet)) + for id := range envIDSet { + envIDs = append(envIDs, id) + } + var found []string + if err := tx.Model(&models.Environment{}).Where("id IN ?", envIDs).Pluck("id", &found).Error; err != nil { + return fmt.Errorf("failed to verify environment ids: %w", err) + } + foundSet := make(map[string]struct{}, len(found)) + for _, id := range found { + foundSet[id] = struct{}{} + } + for id := range envIDSet { + if _, ok := foundSet[id]; !ok { + return &common.InvalidRoleAssignmentError{EnvironmentID: id} + } + } + } + return nil +} + +// ReplaceOidcAssignments replaces the user's source='oidc' assignments. Manual +// assignments are untouched. Enforces the global-admin guard after the swap. +func (s *RoleService) ReplaceOidcAssignments(ctx context.Context, userID string, desired []models.UserRoleAssignment) error { + for i := range desired { + desired[i].UserID = userID + desired[i].Source = models.RoleAssignmentSourceOidc + } + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Where("user_id = ? AND source = ?", userID, models.RoleAssignmentSourceOidc). + Delete(&models.UserRoleAssignment{}).Error; err != nil { + return fmt.Errorf("failed to clear oidc assignments: %w", err) + } + if len(desired) > 0 { + if err := tx.Create(&desired).Error; err != nil { + return fmt.Errorf("failed to insert oidc assignments: %w", err) + } + } + count, err := s.countGlobalAdminsInternal(ctx, tx) + if err != nil { + return err + } + if count == 0 { + return &common.NoGlobalAdminRemainsError{} + } + return nil + }) + if err != nil { + return err + } + s.userCache.Delete(userID) + return nil +} + +// CountGlobalAdminsExcludingUser returns the number of distinct users (other +// than excludedUserID) that hold a global Admin assignment. Used as the +// authoritative check for "removing this user / demoting this assignment +// would leave the system with no admin." +func (s *RoleService) CountGlobalAdminsExcludingUser(ctx context.Context, excludedUserID string) (int, error) { + var count int64 + if err := s.db.WithContext(ctx). + Model(&models.UserRoleAssignment{}). + Distinct("user_id"). + Where("role_id = ? AND environment_id IS NULL AND user_id <> ?", authz.BuiltInRoleAdmin, excludedUserID). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count global admins: %w", err) + } + return int(count), nil +} + +func (s *RoleService) countGlobalAdminsInternal(_ context.Context, tx *gorm.DB) (int, error) { + var count int64 + if err := tx.Model(&models.UserRoleAssignment{}). + Distinct("user_id"). + Where("role_id = ? AND environment_id IS NULL", authz.BuiltInRoleAdmin). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count global admins: %w", err) + } + return int(count), nil +} + +// ---------- Permission resolution ---------- + +// ResolvePermissions returns the effective PermissionSet for a user, caching +// the result per-user for permissionCacheTTL. +func (s *RoleService) ResolvePermissions(ctx context.Context, user *models.User) (*authz.PermissionSet, error) { + if user == nil { + return authz.NewPermissionSet(), nil + } + if ps, ok := s.userCache.Get(user.ID); ok { + return ps, nil + } + ps, err := s.resolveUserPermissionsInternal(ctx, s.db.WithContext(ctx), user.ID) + if err != nil { + return nil, err + } + s.userCache.Put(user.ID, ps) + return ps, nil +} + +func (s *RoleService) resolveUserPermissionsInternal(_ context.Context, tx *gorm.DB, userID string) (*authz.PermissionSet, error) { + // Scan into raw string for the permissions JSON column to avoid GORM's + // schema-introspection on anonymous local structs (which can't see the + // type tags needed to wire models.StringSlice's Scanner). + type row struct { + Permissions string `gorm:"column:permissions"` + EnvironmentID *string `gorm:"column:environment_id"` + } + var rows []row + if err := tx.Table("user_role_assignments AS ura"). + Select("r.permissions AS permissions, ura.environment_id AS environment_id"). + Joins("INNER JOIN roles r ON r.id = ura.role_id"). + Where("ura.user_id = ?", userID). + Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to resolve user permissions: %w", err) + } + ps := authz.NewPermissionSet() + for _, r := range rows { + perms, err := decodePermissionsJSONInternal(r.Permissions) + if err != nil { + return nil, fmt.Errorf("failed to decode role permissions: %w", err) + } + if r.EnvironmentID == nil { + ps.AddGlobal(perms...) + } else { + ps.AddEnv(*r.EnvironmentID, perms...) + } + } + return ps, nil +} + +// decodePermissionsJSONInternal parses the JSON-encoded `roles.permissions` column +// into a string slice. The column is `[]` for an empty role. +func decodePermissionsJSONInternal(raw string) ([]string, error) { + if raw == "" || raw == "[]" { + return nil, nil + } + var out []string + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, err + } + return out, nil +} + +// ResolveApiKeyPermissions returns the PermissionSet for an API key. Caches +// per-key. Falls back to an empty set (deny-all) if the key has no perms. +func (s *RoleService) ResolveApiKeyPermissions(ctx context.Context, apiKeyID string) (*authz.PermissionSet, error) { + if ps, ok := s.apiKeyCache.Get(apiKeyID); ok { + return ps, nil + } + var perms []models.ApiKeyPermission + if err := s.db.WithContext(ctx).Where("api_key_id = ?", apiKeyID).Find(&perms).Error; err != nil { + return nil, fmt.Errorf("failed to resolve api key permissions: %w", err) + } + ps := authz.NewPermissionSet() + for _, p := range perms { + if p.EnvironmentID == nil { + ps.AddGlobal(p.Permission) + } else { + ps.AddEnv(*p.EnvironmentID, p.Permission) + } + } + s.apiKeyCache.Put(apiKeyID, ps) + return ps, nil +} + +// SetApiKeyPermissions replaces every permission row on the given API key +// atomically. Validation that the granted permissions don't exceed the +// creator's capabilities happens in the handler layer. +func (s *RoleService) SetApiKeyPermissions(ctx context.Context, apiKeyID string, grants []models.ApiKeyPermission) error { + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + return s.setApiKeyPermissionsInternal(ctx, tx, apiKeyID, grants) + }) + if err != nil { + return err + } + s.apiKeyCache.Delete(apiKeyID) + return nil +} + +func (s *RoleService) setApiKeyPermissionsInternal(ctx context.Context, tx *gorm.DB, apiKeyID string, grants []models.ApiKeyPermission) error { + for i := range grants { + grants[i].ApiKeyID = apiKeyID + } + if err := tx.WithContext(ctx).Where("api_key_id = ?", apiKeyID).Delete(&models.ApiKeyPermission{}).Error; err != nil { + return fmt.Errorf("failed to clear api key permissions: %w", err) + } + if len(grants) > 0 { + if err := tx.WithContext(ctx).Create(&grants).Error; err != nil { + return fmt.Errorf("failed to insert api key permissions: %w", err) + } + } + return nil +} + +// ---------- OIDC role mappings ---------- + +func (s *RoleService) ListOidcMappings(ctx context.Context) ([]models.OidcRoleMapping, error) { + var out []models.OidcRoleMapping + if err := s.db.WithContext(ctx).Order("claim_value, role_id").Find(&out).Error; err != nil { + return nil, fmt.Errorf("failed to list oidc mappings: %w", err) + } + return out, nil +} + +func (s *RoleService) GetOidcMapping(ctx context.Context, id string) (*models.OidcRoleMapping, error) { + return dbutil.FirstWhere[models.OidcRoleMapping](ctx, s.db.DB, &common.OidcMappingNotFoundError{}, "id = ?", id) +} + +func (s *RoleService) CreateOidcMapping(ctx context.Context, claimValue, roleID string, environmentID *string) (*models.OidcRoleMapping, error) { + if strings.TrimSpace(claimValue) == "" { + return nil, fmt.Errorf("claim value is required") + } + if strings.TrimSpace(roleID) == "" { + return nil, fmt.Errorf("role id is required") + } + mapping := &models.OidcRoleMapping{ + ClaimValue: claimValue, + RoleID: roleID, + EnvironmentID: environmentID, + Source: models.OidcMappingSourceManual, + } + if err := s.db.WithContext(ctx).Create(mapping).Error; err != nil { + return nil, fmt.Errorf("failed to create oidc mapping: %w", err) + } + return mapping, nil +} + +func (s *RoleService) UpdateOidcMapping(ctx context.Context, id, claimValue, roleID string, environmentID *string) (*models.OidcRoleMapping, error) { + var out models.OidcRoleMapping + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.OidcRoleMapping + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.OidcMappingNotFoundError{} + } + return fmt.Errorf("failed to load mapping: %w", err) + } + if existing.Source == models.OidcMappingSourceEnv { + return &common.OidcMappingEnvManagedError{} + } + existing.ClaimValue = claimValue + existing.RoleID = roleID + existing.EnvironmentID = environmentID + if err := tx.Save(&existing).Error; err != nil { + return fmt.Errorf("failed to update mapping: %w", err) + } + out = existing + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *RoleService) DeleteOidcMapping(ctx context.Context, id string) error { + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.OidcRoleMapping + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.OidcMappingNotFoundError{} + } + return fmt.Errorf("failed to load mapping: %w", err) + } + if existing.Source == models.OidcMappingSourceEnv { + return &common.OidcMappingEnvManagedError{} + } + if err := tx.Delete(&models.OidcRoleMapping{}, "id = ?", id).Error; err != nil { + return fmt.Errorf("failed to delete mapping: %w", err) + } + return nil + }) +} + +// ReconcileEnvOidcMappings replaces every source='env' row in oidc_role_mappings +// with the set declared by `rawSpec` (a JSON array of role.OidcRoleMappingSpec). +// Called once at boot. Behavior is declarative: +// +// - rawSpec empty / unset → leaves DB rows alone (purely UI-managed mode). +// - rawSpec is `[]` → wipes any previously-env-managed rows. +// - rawSpec is a valid JSON array → upserts each spec, deletes stale env rows. +// +// Manual rows (source='manual') are never touched. Bad JSON or an unknown role +// ID returns an error so a misconfigured deployment fails loudly rather than +// silently dropping mappings. +func (s *RoleService) ReconcileEnvOidcMappings(ctx context.Context, rawSpec string) error { + rawSpec = strings.TrimSpace(rawSpec) + if rawSpec == "" { + return nil + } + var specs []roletypes.OidcRoleMappingSpec + if err := json.Unmarshal([]byte(rawSpec), &specs); err != nil { + return fmt.Errorf("invalid OIDC_ROLE_MAPPINGS JSON: %w", err) + } + for i, sp := range specs { + if strings.TrimSpace(sp.ClaimValue) == "" { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: claimValue is required", i) + } + if strings.TrimSpace(sp.RoleID) == "" { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: roleId is required", i) + } + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + // Verify every referenced role exists. Done inside the tx so a concurrent + // role delete can't race past this check. + for i, sp := range specs { + var count int64 + if err := tx.Model(&models.Role{}).Where("id = ?", sp.RoleID).Count(&count).Error; err != nil { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: failed to verify role: %w", i, err) + } + if count == 0 { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: role %q does not exist", i, sp.RoleID) + } + } + + // Declarative replace: drop every env-managed row, then insert the new + // set. Manual rows are untouched. + if err := tx.Where("source = ?", models.OidcMappingSourceEnv).Delete(&models.OidcRoleMapping{}).Error; err != nil { + return fmt.Errorf("failed to clear env-managed mappings: %w", err) + } + if len(specs) == 0 { + slog.InfoContext(ctx, "OIDC_ROLE_MAPPINGS reconciled (empty)", "envManagedCount", 0) + return nil + } + rows := make([]models.OidcRoleMapping, len(specs)) + for i, sp := range specs { + rows[i] = models.OidcRoleMapping{ + ClaimValue: sp.ClaimValue, + RoleID: sp.RoleID, + EnvironmentID: sp.EnvironmentID, + Source: models.OidcMappingSourceEnv, + } + } + if err := tx.Create(&rows).Error; err != nil { + return fmt.Errorf("failed to insert env-managed mappings: %w", err) + } + slog.InfoContext(ctx, "OIDC_ROLE_MAPPINGS reconciled", "envManagedCount", len(rows)) + return nil + }) +} + +// ---------- Cache helpers ---------- + +// InvalidateUser drops the cached PermissionSet for one user. Called from +// auth_service after a login that mutates assignments, and from any mutation +// path that doesn't already invalidate explicitly. +func (s *RoleService) InvalidateUser(userID string) { + s.userCache.Delete(userID) +} + +// InvalidateApiKey drops the cached PermissionSet for one API key. +func (s *RoleService) InvalidateApiKey(apiKeyID string) { + s.apiKeyCache.Delete(apiKeyID) +} + +// invalidateUsersAssignedToInternal invalidates every user holding an assignment to +// the given role. Called after a role's permissions change. +func (s *RoleService) invalidateUsersAssignedToInternal(ctx context.Context, roleID string) { + var userIDs []string + if err := s.db.WithContext(ctx). + Model(&models.UserRoleAssignment{}). + Distinct("user_id"). + Where("role_id = ?", roleID). + Pluck("user_id", &userIDs).Error; err != nil { + slog.WarnContext(ctx, "failed to collect users for cache invalidation", "error", err, "role_id", roleID) + return + } + for _, id := range userIDs { + s.userCache.Delete(id) + } +} + +// ---------- helpers ---------- + +func validatePermissionsInternal(perms []string) error { + for _, p := range perms { + if !authz.IsKnownPermission(p) { + return &common.UnknownPermissionError{Perm: p} + } + } + return nil +} + +// ValidatePermissionsAgainstCaller rejects any permission in `desired` that the +// caller does not hold at global scope. Sudo callers (agent / env access +// tokens, bootstrap paths) bypass entirely. Holding a permission only inside a +// specific environment is intentionally insufficient: roles are reusable +// templates that can later be assigned globally, so an env-scoped grant must +// not let the caller mint a global-capable role. +// +// Unknown permission strings are rejected first with an UnknownPermissionError +// so a caller typo-ing a permission gets a descriptive 400 instead of a +// misleading 403 from the escalation guard below (which would always fire on +// an unknown perm because no PermissionSet contains it). This also gives the +// escalation loop a clean invariant: every perm reaching it is real. +// +// Callers should run this before persisting role permissions to defend against +// privilege escalation if the role mutation endpoints are ever exposed beyond +// global admins. +func (s *RoleService) ValidatePermissionsAgainstCaller(caller *authz.PermissionSet, desired []string) error { + if err := validatePermissionsInternal(desired); err != nil { + return err + } + if caller == nil { + if len(desired) == 0 { + return nil + } + return &common.RolePermissionEscalationError{Perm: desired[0]} + } + if caller.Sudo { + return nil + } + for _, p := range desired { + if !caller.Allows(p, "") { + return &common.RolePermissionEscalationError{Perm: p} + } + } + return nil +} diff --git a/backend/internal/services/role_service_test.go b/backend/internal/services/role_service_test.go new file mode 100644 index 0000000000..8b60f70aae --- /dev/null +++ b/backend/internal/services/role_service_test.go @@ -0,0 +1,104 @@ +package services + +import ( + "context" + "slices" + "testing" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/stretchr/testify/require" +) + +func TestBackfillPermsForKeyDeduplicatesGlobalAndEnvironmentPermissions(t *testing.T) { + ctx := context.Background() + userSvc, roleSvc := setupUserAndRoleServices(t) + admin := createTestUser(t, userSvc, "admin", "admin") + grantGlobalAdmin(t, roleSvc, admin.ID) + user := createTestUser(t, userSvc, "api-key-owner", "api-key-owner") + envID := "env-1" + createTestEnvironment(t, roleSvc.db, envID, "http://localhost:3552", nil) + + require.NoError(t, roleSvc.SetUserAssignments(ctx, user.ID, []models.UserRoleAssignment{ + {RoleID: authz.BuiltInRoleViewer, EnvironmentID: nil}, + {RoleID: authz.BuiltInRoleEditor, EnvironmentID: &envID}, + })) + + perms, err := roleSvc.backfillPermsForKeyInternal(ctx, roleSvc.db.WithContext(ctx), models.ApiKey{ + UserID: &user.ID, + EnvironmentID: &envID, + }) + require.NoError(t, err) + require.Contains(t, perms, authz.PermContainersList) + require.Equal(t, 1, countPermissionInternal(perms, authz.PermContainersList)) +} + +func countPermissionInternal(perms []string, permission string) int { + return len(slices.DeleteFunc(slices.Clone(perms), func(p string) bool { + return p != permission + })) +} + +func TestValidatePermissionsAgainstCallerRejectsEscalation(t *testing.T) { + _, roleSvc := setupUserAndRoleServices(t) + + caller := authz.NewPermissionSet() + caller.AddGlobal(authz.PermRolesRead, authz.PermRolesList) + + err := roleSvc.ValidatePermissionsAgainstCaller(caller, []string{ + authz.PermRolesRead, + authz.PermUsersDelete, + }) + require.Error(t, err) + require.True(t, common.IsRolePermissionEscalationError(err)) + + require.NoError(t, roleSvc.ValidatePermissionsAgainstCaller(caller, []string{authz.PermRolesRead})) + require.NoError(t, roleSvc.ValidatePermissionsAgainstCaller(authz.SudoPermissionSet(), []string{authz.PermUsersDelete})) +} + +func TestValidatePermissionsAgainstCallerRejectsEnvOnlyGrantForGlobalRole(t *testing.T) { + _, roleSvc := setupUserAndRoleServices(t) + + caller := authz.NewPermissionSet() + caller.AddEnv("env-1", authz.PermContainersStart) + + err := roleSvc.ValidatePermissionsAgainstCaller(caller, []string{authz.PermContainersStart}) + require.Error(t, err) + require.True(t, common.IsRolePermissionEscalationError(err)) +} + +func TestValidatePermissionsAgainstCallerRejectsUnknownPermissionBeforeEscalation(t *testing.T) { + _, roleSvc := setupUserAndRoleServices(t) + + // A sudo caller would otherwise short-circuit past the escalation loop; + // unknown perms must still surface as UnknownPermissionError (→ 400), + // not as an opaque escalation 403 or a silent pass. + err := roleSvc.ValidatePermissionsAgainstCaller(authz.SudoPermissionSet(), []string{"containrs:start"}) + require.Error(t, err) + require.True(t, common.IsUnknownPermissionError(err)) + require.False(t, common.IsRolePermissionEscalationError(err)) +} + +func TestBackfillLegacyRoleAssignmentsIsNoOpWhenColumnAbsent(t *testing.T) { + ctx := context.Background() + _, roleSvc := setupUserAndRoleServices(t) + // setupUserAndRoleServices runs migrations through to current, so + // users.roles never exists in the fresh test schema. + require.False(t, roleSvc.db.Migrator().HasColumn("users", "roles")) + require.NoError(t, roleSvc.BackfillLegacyRoleAssignments(ctx)) + // Idempotent — second call is also fine. + require.NoError(t, roleSvc.BackfillLegacyRoleAssignments(ctx)) +} + +func TestSetUserAssignmentsRejectsUnknownRole(t *testing.T) { + ctx := context.Background() + userSvc, roleSvc := setupUserAndRoleServices(t) + user := createTestUser(t, userSvc, "victim", "victim") + + err := roleSvc.SetUserAssignments(ctx, user.ID, []models.UserRoleAssignment{ + {RoleID: "role_does_not_exist"}, + }) + require.Error(t, err) + require.True(t, common.IsInvalidRoleAssignmentError(err)) +} diff --git a/backend/internal/services/session_service_test.go b/backend/internal/services/session_service_test.go index 31fca49fe3..c4e5e9a5f8 100644 --- a/backend/internal/services/session_service_test.go +++ b/backend/internal/services/session_service_test.go @@ -17,7 +17,6 @@ func TestSessionService_RotateRefreshTokenRequiresCurrentHash(t *testing.T) { require.NoError(t, userSvc.db.Create(&models.User{ BaseModel: models.BaseModel{ID: "u-session"}, Username: "session-user", - Roles: models.StringSlice{"user"}, }).Error) sessionSvc := NewSessionService(db) @@ -41,7 +40,6 @@ func TestSessionService_DeleteExpiredSessions(t *testing.T) { require.NoError(t, userSvc.db.Create(&models.User{ BaseModel: models.BaseModel{ID: "u-cleanup"}, Username: "cleanup-user", - Roles: models.StringSlice{"user"}, }).Error) sessionSvc := NewSessionService(db) diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index 906db6cf0c..060206d830 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -167,8 +167,7 @@ func DefaultSettingsConfig() *models.Settings { OidcUserinfoEndpoint: models.SettingVariable{Value: ""}, OidcJwksEndpoint: models.SettingVariable{Value: ""}, OidcScopes: models.SettingVariable{Value: "openid email profile"}, - OidcAdminClaim: models.SettingVariable{Value: ""}, - OidcAdminValue: models.SettingVariable{Value: ""}, + OidcGroupsClaim: models.SettingVariable{Value: "groups"}, OidcSkipTlsVerify: models.SettingVariable{Value: "false"}, OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"}, OidcMergeAccounts: models.SettingVariable{Value: "false"}, diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go index 7a34d7389b..93149354e4 100644 --- a/backend/internal/services/user_service.go +++ b/backend/internal/services/user_service.go @@ -17,6 +17,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" "github.com/getarcaneapp/arcane/types/user" @@ -42,6 +43,7 @@ func DefaultArgon2Params() *Argon2Params { type UserService struct { db *database.DB + roleService *RoleService argon2Params *Argon2Params } @@ -54,6 +56,14 @@ func NewUserService(db *database.DB) *UserService { } } +// WithRoleService wires the RoleService dependency. Separated from the +// constructor so the bootstrap can construct UserService first (RoleService +// itself has no UserService dependency). +func (s *UserService) WithRoleService(roleService *RoleService) *UserService { + s.roleService = roleService + return s +} + func (s *UserService) hashPassword(password string) (string, error) { salt := make([]byte, s.argon2Params.saltLength) _, err := rand.Read(salt) @@ -163,27 +173,15 @@ func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models func (s *UserService) UpdateUser(ctx context.Context, user *models.User) (*models.User, error) { err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { - var existing models.User if err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", user.ID). - First(&existing).Error; err != nil { + First(&models.User{}).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrUserNotFound } return fmt.Errorf("failed to load user: %w", err) } - - if userHasRoleInternal(existing.Roles, "admin") && !userHasRoleInternal(user.Roles, "admin") { - remainingAdmins, err := s.remainingAdminCountExcludingUserInternal(tx, user.ID) - if err != nil { - return err - } - if remainingAdmins == 0 { - return ErrCannotRemoveLastAdmin - } - } - if err := tx.Save(user).Error; err != nil { return fmt.Errorf("failed to update user: %w", err) } @@ -251,64 +249,127 @@ func (s *UserService) CreateDefaultAdmin(ctx context.Context) error { return fmt.Errorf("failed to hash default admin password: %w", err) } - return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + // Step 1: ensure the default admin user row exists. If the users table is + // empty we create it; otherwise we find the existing `arcane` user (if any). + // Either way the role assignment is reconciled below — idempotently — so + // upgrades from older builds that didn't grant the role get patched up. + var adminUserID string + err = dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { var count int64 if err := tx.Model(&models.User{}).Count(&count).Error; err != nil { return fmt.Errorf("failed to count users: %w", err) } - if count > 0 { - slog.WarnContext(ctx, "Users already exist, skipping default admin creation") + if count == 0 { + email := "admin@localhost" + displayName := "Arcane Admin" + userModel := &models.User{ + Username: "arcane", + Email: new(email), + DisplayName: new(displayName), + PasswordHash: hashedPassword, + RequiresPasswordChange: true, + } + if err := tx.Create(userModel).Error; err != nil { + return fmt.Errorf("failed to create default admin user: %w", err) + } + adminUserID = userModel.ID + slog.InfoContext(ctx, "👑 Default admin user created!") + slog.InfoContext(ctx, "🔑 Username: arcane") + slog.InfoContext(ctx, "🔑 Password: arcane-admin") + slog.InfoContext(ctx, "⚠️ User will be prompted to change password on first login") return nil } - email := "admin@localhost" - displayName := "Arcane Admin" - userModel := &models.User{ - Username: "arcane", - Email: new(email), - DisplayName: new(displayName), - PasswordHash: hashedPassword, - Roles: models.StringSlice{"admin"}, - RequiresPasswordChange: true, - } - - if err := tx.Create(userModel).Error; err != nil { - return fmt.Errorf("failed to create default admin user: %w", err) + // Users already exist — see if `arcane` is one of them. If not, + // someone removed the default admin on purpose and we leave it alone. + var existing models.User + err := tx.Where("username = ?", "arcane").First(&existing).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return fmt.Errorf("failed to look up default admin user: %w", err) } + adminUserID = existing.ID + return nil + }) + if err != nil { + return err + } - slog.InfoContext(ctx, "👑 Default admin user created!") - slog.InfoContext(ctx, "🔑 Username: arcane") - slog.InfoContext(ctx, "🔑 Password: arcane-admin") - slog.InfoContext(ctx, "⚠️ User will be prompted to change password on first login") - + if adminUserID == "" || s.roleService == nil { return nil + } + + // Step 2: ensure the default admin holds the global Admin role assignment. + // Idempotent — if the assignment already exists, SetUserAssignments is a + // no-op write (it dedupes via the unique index). This recovers users that + // were created by older builds before the role-grant on bootstrap was + // wired up. + assignments, err := s.roleService.ListUserAssignments(ctx, adminUserID) + if err != nil { + return fmt.Errorf("failed to list default admin assignments: %w", err) + } + for _, a := range assignments { + if a.RoleID == authz.BuiltInRoleAdmin && a.EnvironmentID == nil { + return nil // already has it + } + } + desired := append([]models.UserRoleAssignment{}, assignments...) + desired = append(desired, models.UserRoleAssignment{ + RoleID: authz.BuiltInRoleAdmin, + EnvironmentID: nil, }) + // Strip the source field so SetUserAssignments classifies everything as manual. + manual := make([]models.UserRoleAssignment, 0, len(desired)) + for _, a := range desired { + manual = append(manual, models.UserRoleAssignment{RoleID: a.RoleID, EnvironmentID: a.EnvironmentID}) + } + if err := s.roleService.SetUserAssignments(ctx, adminUserID, manual); err != nil { + return fmt.Errorf("failed to grant default admin global role: %w", err) + } + slog.InfoContext(ctx, "Default admin granted global Admin role assignment", "user_id", adminUserID) + return nil } func (s *UserService) DeleteUser(ctx context.Context, id string) error { + // Last-admin guard: if this user holds the only global Admin assignment, + // refuse the delete. Checked OUTSIDE the transaction because RoleService + // uses its own session — and the guard is informational only (the unique + // index in user_role_assignments wouldn't catch this cross-row condition). + if s.roleService != nil { + var holdsGlobalAdmin bool + assignments, err := s.roleService.ListUserAssignments(ctx, id) + if err == nil { + for _, a := range assignments { + if a.RoleID == authz.BuiltInRoleAdmin && a.EnvironmentID == nil { + holdsGlobalAdmin = true + break + } + } + } + if holdsGlobalAdmin { + remaining, err := s.roleService.CountGlobalAdminsExcludingUser(ctx, id) + if err != nil { + return err + } + if remaining == 0 { + return ErrCannotRemoveLastAdmin + } + } + } return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { - var existing models.User if err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", id). - First(&existing).Error; err != nil { + First(&models.User{}).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrUserNotFound } return fmt.Errorf("failed to load user: %w", err) } - if userHasRoleInternal(existing.Roles, "admin") { - remainingAdmins, err := s.remainingAdminCountExcludingUserInternal(tx, id) - if err != nil { - return err - } - if remainingAdmins == 0 { - return ErrCannotRemoveLastAdmin - } - } - if err := tx.Delete(&models.User{}, "id = ?", id).Error; err != nil { return fmt.Errorf("failed to delete user: %w", err) } @@ -366,46 +427,90 @@ func (s *UserService) ListUsersPaginated(ctx context.Context, params pagination. } func (s *UserService) ToUserResponseDto(ctx context.Context, u models.User) (user.User, error) { - if !userHasRoleInternal(u.Roles, "admin") { - return toUserResponseDtoInternal(u, 0), nil - } - - adminCount, err := s.adminUserCountInternal(ctx) - if err != nil { - return user.User{}, err - } - - return toUserResponseDtoInternal(u, adminCount), nil + return s.toUserResponseDtoInternal(ctx, u), nil } func (s *UserService) toUserResponseDtosInternal(ctx context.Context, users []models.User) ([]user.User, error) { - adminCount, err := s.adminUserCountInternal(ctx) - if err != nil { - return nil, err - } - result := make([]user.User, len(users)) for i, u := range users { - result[i] = toUserResponseDtoInternal(u, adminCount) + result[i] = s.toUserResponseDtoInternal(ctx, u) } - return result, nil } -func toUserResponseDtoInternal(u models.User, adminCount int) user.User { - return user.User{ +// toUserResponseDtoInternal builds the public User DTO. RoleAssignments and +// PermissionsByEnv come from the RBAC service. CanDelete is false when this +// user holds the only global Admin assignment (i.e. removing them would orphan +// the instance). +func (s *UserService) toUserResponseDtoInternal(ctx context.Context, u models.User) user.User { + dto := user.User{ ID: u.ID, Username: u.Username, DisplayName: u.DisplayName, Email: u.Email, - Roles: u.Roles, - CanDelete: !userHasRoleInternal(u.Roles, "admin") || adminCount > 1, + CanDelete: true, OidcSubjectId: u.OidcSubjectId, Locale: u.Locale, RequiresPasswordChange: u.RequiresPasswordChange, CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05.999999Z"), UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05.999999Z"), + RoleAssignments: []user.RoleAssignmentSummary{}, + PermissionsByEnv: map[string][]string{}, + } + if s.roleService == nil { + return dto + } + if rows, err := s.roleService.ListUserAssignments(ctx, u.ID); err == nil { + dto.RoleAssignments = make([]user.RoleAssignmentSummary, len(rows)) + for i, r := range rows { + dto.RoleAssignments[i] = user.RoleAssignmentSummary{ + RoleID: r.RoleID, + EnvironmentID: r.EnvironmentID, + Source: r.Source, + } + // Last-admin guard: this user is non-deletable if they hold the + // only global Admin assignment. + if r.RoleID == authz.BuiltInRoleAdmin && r.EnvironmentID == nil { + if remaining, cerr := s.roleService.CountGlobalAdminsExcludingUser(ctx, u.ID); cerr == nil && remaining == 0 { + dto.CanDelete = false + } + } + } + } + if ps, err := s.roleService.ResolvePermissions(ctx, &u); err == nil && ps != nil { + dto.PermissionsByEnv = permissionSetToMap(ps) + } + return dto +} + +// permissionSetToMap flattens a PermissionSet into the wire format consumed +// by the frontend: a map from environment ID (or "global") to a list of +// permission strings. Sudo callers expose "*" under "global" as a sentinel +// meaning "every permission". +func permissionSetToMap(ps *authz.PermissionSet) map[string][]string { + out := map[string][]string{} + if ps == nil { + return out } + if ps.Sudo { + out["global"] = []string{"*"} + return out + } + if len(ps.Global) > 0 { + globals := make([]string, 0, len(ps.Global)) + for p := range ps.Global { + globals = append(globals, p) + } + out["global"] = globals + } + for envID, perms := range ps.PerEnv { + list := make([]string, 0, len(perms)) + for p := range perms { + list = append(list, p) + } + out[envID] = list + } + return out } func (s *UserService) GetUser(ctx context.Context, userID string) (*models.User, error) { @@ -422,49 +527,3 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go Error return &user, err } - -func (s *UserService) remainingAdminCountExcludingUserInternal(tx *gorm.DB, excludedUserID string) (int, error) { - var count int64 - if err := s.adminUsersScopeInternal(tx.Model(&models.User{}).Where("id <> ?", excludedUserID)).Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count remaining admin users: %w", err) - } - - return int(count), nil -} - -func userHasRoleInternal(roles models.StringSlice, role string) bool { - for _, currentRole := range roles { - if strings.EqualFold(currentRole, role) { - return true - } - } - - return false -} - -func (s *UserService) adminUserCountInternal(ctx context.Context) (int, error) { - var count int64 - if err := s.adminUsersScopeInternal(s.db.WithContext(ctx).Model(&models.User{})).Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count admin users: %w", err) - } - - return int(count), nil -} - -func (s *UserService) adminUsersScopeInternal(query *gorm.DB) *gorm.DB { - switch s.db.Name() { - case "sqlite": - return query.Where( - "EXISTS (SELECT 1 FROM json_each(users.roles) WHERE lower(json_each.value) = ?)", - "admin", - ) - case "postgres": - return query.Where( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(users.roles::jsonb) AS role WHERE lower(role) = ?)", - "admin", - ) - default: - slog.Warn("Using LIKE-based admin role query fallback for unsupported database dialect", "dialect", s.db.Name()) - return query.Where("LOWER(roles) LIKE ?", `%\"admin\"%`) - } -} diff --git a/backend/internal/services/user_service_test.go b/backend/internal/services/user_service_test.go index 7d0e1da1dd..e82054f771 100644 --- a/backend/internal/services/user_service_test.go +++ b/backend/internal/services/user_service_test.go @@ -5,114 +5,95 @@ import ( "testing" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/stretchr/testify/require" ) -func createTestUser(t *testing.T, svc *UserService, id, username string, roles models.StringSlice) *models.User { +// setupUserAndRoleServices wires both services together the way bootstrap +// does, so the legacy-admin-guard tests exercise the real RBAC path. +func setupUserAndRoleServices(t *testing.T) (*UserService, *RoleService) { t.Helper() + db := setupAuthServiceTestDB(t) + role := NewRoleService(db) + require.NoError(t, role.EnsureBuiltInRoles(context.Background())) + user := NewUserService(db).WithRoleService(role) + return user, role +} - user := &models.User{ +func createTestUser(t *testing.T, svc *UserService, id, username string) *models.User { + t.Helper() + created, err := svc.CreateUser(context.Background(), &models.User{ BaseModel: models.BaseModel{ID: id}, Username: username, - Roles: roles, - } - - created, err := svc.CreateUser(context.Background(), user) + }) require.NoError(t, err) - return created } +// grantGlobalAdmin assigns the built-in Admin role globally to the user. +func grantGlobalAdmin(t *testing.T, role *RoleService, userID string) { + t.Helper() + require.NoError(t, role.SetUserAssignments(context.Background(), userID, []models.UserRoleAssignment{ + {RoleID: authz.BuiltInRoleAdmin, EnvironmentID: nil}, + })) +} + func TestDeleteUserRejectsDeletingOnlyAdmin(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - admin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"Admin"}) + admin := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, admin.ID) - err := svc.DeleteUser(ctx, admin.ID) + err := userSvc.DeleteUser(ctx, admin.ID) require.ErrorIs(t, err, ErrCannotRemoveLastAdmin) - stillThere, err := svc.GetUserByID(ctx, admin.ID) + stillThere, err := userSvc.GetUserByID(ctx, admin.ID) require.NoError(t, err) require.Equal(t, admin.ID, stillThere.ID) } func TestDeleteUserAllowsDeletingNonAdmin(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"admin"}) - nonAdmin := createTestUser(t, svc, "user-1", "user", models.StringSlice{"user"}) + admin := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, admin.ID) + nonAdmin := createTestUser(t, userSvc, "user-1", "user") - err := svc.DeleteUser(ctx, nonAdmin.ID) + err := userSvc.DeleteUser(ctx, nonAdmin.ID) require.NoError(t, err) - _, err = svc.GetUserByID(ctx, nonAdmin.ID) + _, err = userSvc.GetUserByID(ctx, nonAdmin.ID) require.ErrorIs(t, err, ErrUserNotFound) } func TestDeleteUserAllowsDeletingAdminWhenAnotherAdminExists(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - adminToDelete := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"Admin"}) - createTestUser(t, svc, "admin-2", "backup", models.StringSlice{"admin"}) + adminToDelete := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, adminToDelete.ID) + backup := createTestUser(t, userSvc, "admin-2", "backup") + grantGlobalAdmin(t, roleSvc, backup.ID) - err := svc.DeleteUser(ctx, adminToDelete.ID) + err := userSvc.DeleteUser(ctx, adminToDelete.ID) require.NoError(t, err) - _, err = svc.GetUserByID(ctx, adminToDelete.ID) + _, err = userSvc.GetUserByID(ctx, adminToDelete.ID) require.ErrorIs(t, err, ErrUserNotFound) } -func TestUpdateUserRejectsRemovingAdminFromOnlyAdmin(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) - ctx := context.Background() - - admin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"ADMIN"}) - admin.Roles = models.StringSlice{"user"} - - _, err := svc.UpdateUser(ctx, admin) - require.ErrorIs(t, err, ErrCannotRemoveLastAdmin) - - persisted, err := svc.GetUserByID(ctx, admin.ID) - require.NoError(t, err) - require.Equal(t, models.StringSlice{"ADMIN"}, persisted.Roles) -} - -func TestUpdateUserAllowsRemovingAdminWhenAnotherAdminExists(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) - ctx := context.Background() - - admin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"admin"}) - createTestUser(t, svc, "admin-2", "backup", models.StringSlice{"ADMIN"}) - - admin.Roles = models.StringSlice{"user"} - - updated, err := svc.UpdateUser(ctx, admin) - require.NoError(t, err) - require.Equal(t, models.StringSlice{"user"}, updated.Roles) - - persisted, err := svc.GetUserByID(ctx, admin.ID) - require.NoError(t, err) - require.Equal(t, models.StringSlice{"user"}, persisted.Roles) -} - func TestListUsersPaginatedSetsCanDeleteFromGlobalAdminCount(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - lastAdmin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"admin"}) - nonAdmin := createTestUser(t, svc, "user-1", "user", models.StringSlice{"user"}) + lastAdmin := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, lastAdmin.ID) + nonAdmin := createTestUser(t, userSvc, "user-1", "user") - users, _, err := svc.ListUsersPaginated(ctx, pagination.QueryParams{ + users, _, err := userSvc.ListUsersPaginated(ctx, pagination.QueryParams{ PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, SortParams: pagination.SortParams{Sort: "Username", Order: pagination.SortOrder("asc")}, Filters: map[string]string{}, diff --git a/backend/pkg/authz/catalog.go b/backend/pkg/authz/catalog.go new file mode 100644 index 0000000000..54205d6ead --- /dev/null +++ b/backend/pkg/authz/catalog.go @@ -0,0 +1,204 @@ +package authz + +const ( + PermissionScopeGlobal = "global" + PermissionScopeEnv = "env" +) + +// PermissionCatalogResource describes a resource group in the permission +// catalog. It is the authz-owned source for permission ordering, scope, and +// display metadata used by API manifests and validation helpers. +type PermissionCatalogResource struct { + Key string + Label string + Scope string + Actions []PermissionCatalogAction +} + +// PermissionCatalogAction describes one recognized permission. +type PermissionCatalogAction struct { + Key string + Permission string + Label string + Description string +} + +var permissionCatalog = []PermissionCatalogResource{ + {"users", "Users", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermUsersList, "List", ""}, + {"read", PermUsersRead, "Read", ""}, + {"create", PermUsersCreate, "Create", ""}, + {"update", PermUsersUpdate, "Update", ""}, + {"delete", PermUsersDelete, "Delete", ""}, + }}, + {"roles", "Roles", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermRolesList, "List", ""}, + {"read", PermRolesRead, "Read", ""}, + }}, + {"apikeys", "API Keys", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermApiKeysList, "List", ""}, + {"read", PermApiKeysRead, "Read", ""}, + {"create", PermApiKeysCreate, "Create", ""}, + {"update", PermApiKeysUpdate, "Update", ""}, + {"delete", PermApiKeysDelete, "Delete", ""}, + }}, + {"settings", "Settings", PermissionScopeGlobal, []PermissionCatalogAction{ + {"read", PermSettingsRead, "Read", ""}, + {"write", PermSettingsWrite, "Write", ""}, + }}, + {"environments", "Environments", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermEnvironmentsList, "List", ""}, + {"read", PermEnvironmentsRead, "Read", ""}, + {"create", PermEnvironmentsCreate, "Create", ""}, + {"update", PermEnvironmentsUpdate, "Update", ""}, + {"delete", PermEnvironmentsDelete, "Delete", ""}, + {"pair", PermEnvironmentsPair, "Pair agent", ""}, + {"sync", PermEnvironmentsSync, "Sync heartbeat", ""}, + }}, + {"registries", "Container Registries", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermRegistriesList, "List", ""}, + {"read", PermRegistriesRead, "Read", ""}, + {"create", PermRegistriesCreate, "Create", ""}, + {"update", PermRegistriesUpdate, "Update", ""}, + {"delete", PermRegistriesDelete, "Delete", ""}, + {"test", PermRegistriesTest, "Test", ""}, + }}, + {"templates", "Templates", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermTemplatesList, "List", ""}, + {"read", PermTemplatesRead, "Read", ""}, + {"create", PermTemplatesCreate, "Create", ""}, + {"update", PermTemplatesUpdate, "Update", ""}, + {"delete", PermTemplatesDelete, "Delete", ""}, + }}, + {"git-repositories", "Git Repositories", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermGitReposList, "List", ""}, + {"read", PermGitReposRead, "Read", ""}, + {"create", PermGitReposCreate, "Create", ""}, + {"update", PermGitReposUpdate, "Update", ""}, + {"delete", PermGitReposDelete, "Delete", ""}, + {"test", PermGitReposTest, "Test", ""}, + {"sync", PermGitReposSync, "Sync", ""}, + }}, + {"events", "Events", PermissionScopeGlobal, []PermissionCatalogAction{ + {"read", PermEventsRead, "Read", ""}, + }}, + {"customize", "Customize", PermissionScopeGlobal, []PermissionCatalogAction{ + {"manage", PermCustomizeManage, "Manage", ""}, + }}, + {"containers", "Containers", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermContainersList, "List", ""}, + {"read", PermContainersRead, "Read", ""}, + {"logs", PermContainersLogs, "View logs", ""}, + {"create", PermContainersCreate, "Create", ""}, + {"start", PermContainersStart, "Start", ""}, + {"stop", PermContainersStop, "Stop", ""}, + {"restart", PermContainersRestart, "Restart", ""}, + {"redeploy", PermContainersRedeploy, "Redeploy", ""}, + {"delete", PermContainersDelete, "Delete", ""}, + {"exec", PermContainersExec, "Exec / terminal", ""}, + {"autoupdate", PermContainersAutoUpdate, "Auto-update", ""}, + }}, + {"projects", "Projects", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermProjectsList, "List", ""}, + {"read", PermProjectsRead, "Read", ""}, + {"logs", PermProjectsLogs, "View logs", ""}, + {"create", PermProjectsCreate, "Create", ""}, + {"update", PermProjectsUpdate, "Update", ""}, + {"deploy", PermProjectsDeploy, "Deploy", ""}, + {"down", PermProjectsDown, "Bring down", ""}, + {"restart", PermProjectsRestart, "Restart", ""}, + {"delete", PermProjectsDelete, "Delete", ""}, + {"archive", PermProjectsArchive, "Archive / unarchive", ""}, + }}, + {"images", "Images", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermImagesList, "List", ""}, + {"read", PermImagesRead, "Read", ""}, + {"pull", PermImagesPull, "Pull", ""}, + {"push", PermImagesPush, "Push", ""}, + {"build", PermImagesBuild, "Build", ""}, + {"prune", PermImagesPrune, "Prune", ""}, + {"delete", PermImagesDelete, "Delete", ""}, + {"upload", PermImagesUpload, "Upload", ""}, + }}, + {"volumes", "Volumes", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermVolumesList, "List", ""}, + {"read", PermVolumesRead, "Read", ""}, + {"create", PermVolumesCreate, "Create", ""}, + {"delete", PermVolumesDelete, "Delete", ""}, + {"prune", PermVolumesPrune, "Prune", ""}, + {"browse", PermVolumesBrowse, "Browse", ""}, + {"upload", PermVolumesUpload, "Upload", ""}, + {"backup", PermVolumesBackup, "Backup / restore", ""}, + }}, + {"networks", "Networks", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermNetworksList, "List", ""}, + {"read", PermNetworksRead, "Read", ""}, + {"create", PermNetworksCreate, "Create", ""}, + {"delete", PermNetworksDelete, "Delete", ""}, + {"prune", PermNetworksPrune, "Prune", ""}, + }}, + {"swarm", "Swarm", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermSwarmRead, "Read", ""}, + {"init", PermSwarmInit, "Initialize", ""}, + {"join", PermSwarmJoin, "Join", ""}, + {"leave", PermSwarmLeave, "Leave", ""}, + {"spec", PermSwarmSpec, "Update spec", ""}, + {"nodes", PermSwarmNodes, "Manage nodes", ""}, + {"services", PermSwarmServices, "Manage services", ""}, + {"services:logs", PermSwarmServicesLogs, "View service logs", ""}, + {"stacks", PermSwarmStacks, "Manage stacks", ""}, + {"configs", PermSwarmConfigs, "Manage configs", ""}, + {"secrets", PermSwarmSecrets, "Manage secrets", ""}, + {"unlock", PermSwarmUnlock, "Unlock / join tokens", ""}, + }}, + {"gitops", "GitOps Syncs", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermGitOpsList, "List", ""}, + {"read", PermGitOpsRead, "Read", ""}, + {"create", PermGitOpsCreate, "Create", ""}, + {"update", PermGitOpsUpdate, "Update", ""}, + {"delete", PermGitOpsDelete, "Delete", ""}, + {"sync", PermGitOpsSync, "Trigger sync", ""}, + }}, + {"webhooks", "Webhooks", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermWebhooksList, "List", ""}, + {"create", PermWebhooksCreate, "Create", ""}, + {"update", PermWebhooksUpdate, "Update", ""}, + {"delete", PermWebhooksDelete, "Delete", ""}, + }}, + {"jobs", "Background Jobs", PermissionScopeEnv, []PermissionCatalogAction{ + {"manage", PermJobsManage, "Manage", ""}, + }}, + {"notifications", "Notifications", PermissionScopeEnv, []PermissionCatalogAction{ + {"manage", PermNotificationsManage, "Manage", ""}, + }}, + {"dashboard", "Dashboard", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermDashboardRead, "Read", ""}, + }}, + {"system", "System", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermSystemRead, "Read", ""}, + {"prune", PermSystemPrune, "Prune", ""}, + {"upgrade", PermSystemUpgrade, "Trigger upgrade", ""}, + }}, + {"image-updates", "Image Updates", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermImageUpdatesRead, "Read", ""}, + {"check", PermImageUpdatesCheck, "Check", ""}, + }}, + {"vulnerabilities", "Vulnerabilities", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermVulnsRead, "Read", ""}, + {"scan", PermVulnsScan, "Scan", ""}, + {"manage", PermVulnsManage, "Manage ignores", ""}, + }}, + {"build-workspaces", "Build Workspaces", PermissionScopeEnv, []PermissionCatalogAction{ + {"manage", PermBuildWorkspacesManage, "Manage", ""}, + }}, +} + +// PermissionCatalog returns a defensive copy of the full permission catalog. +func PermissionCatalog() []PermissionCatalogResource { + out := make([]PermissionCatalogResource, len(permissionCatalog)) + for i, resource := range permissionCatalog { + out[i] = resource + out[i].Actions = append([]PermissionCatalogAction(nil), resource.Actions...) + } + return out +} diff --git a/backend/pkg/authz/permission_set.go b/backend/pkg/authz/permission_set.go new file mode 100644 index 0000000000..dc76644617 --- /dev/null +++ b/backend/pkg/authz/permission_set.go @@ -0,0 +1,128 @@ +package authz + +import "strings" + +// PermissionSet is the effective permission set for one caller in one request. +// Built once by the auth bridge and stashed in the request context. +// +// Global permissions apply to every environment and to org-level endpoints. +// PerEnv permissions apply only when the caller is acting on that specific +// environment. Sudo bypasses all checks (used for the agent token and the +// per-environment access token paths). +type PermissionSet struct { + Global map[string]struct{} + PerEnv map[string]map[string]struct{} + Sudo bool +} + +// Allows reports whether the caller may perform `perm`. For env-scoped +// permissions, envID is the target environment's ID. For org-level +// permissions, pass envID = "" — only Global permissions count. +func (ps *PermissionSet) Allows(perm, envID string) bool { + if ps == nil { + return false + } + if ps.Sudo { + return true + } + if _, ok := ps.Global[perm]; ok { + return true + } + if envID == "" { + return false + } + if env, ok := ps.PerEnv[envID]; ok { + if _, ok := env[perm]; ok { + return true + } + } + return false +} + +// IsGlobalAdmin reports whether the caller holds enough global permissions to +// be considered an administrator. True for sudo callers and for callers whose +// Global set contains every defined permission. Used by the backward-compat +// IsAdminFromContext helper and by last-admin guards. +// +// Implementation: every insertion path runs through +// services.validatePermissionsInternal → authz.IsKnownPermission, which does +// an exact-set check against AllPermissions(). ps.Global is therefore a true +// subset of AllPermissions, and equal cardinality implies equality — no +// per-call slice allocation or O(N) walk on the auth hot path. +func (ps *PermissionSet) IsGlobalAdmin() bool { + if ps == nil { + return false + } + if ps.Sudo { + return true + } + return len(ps.Global) >= TotalPermissionsCount() +} + +// SudoPermissionSet returns a PermissionSet that allows every action. Used for +// the agent token and environment access token paths, which bypass per-user +// permission resolution entirely. +func SudoPermissionSet() *PermissionSet { + return &PermissionSet{Sudo: true} +} + +// NewPermissionSet builds an empty PermissionSet ready for population. +func NewPermissionSet() *PermissionSet { + return &PermissionSet{ + Global: make(map[string]struct{}), + PerEnv: make(map[string]map[string]struct{}), + } +} + +// AddGlobal grants `perms` at global scope. +func (ps *PermissionSet) AddGlobal(perms ...string) { + if ps.Global == nil { + ps.Global = make(map[string]struct{}) + } + for _, p := range perms { + ps.Global[p] = struct{}{} + } +} + +// AddEnv grants `perms` scoped to envID. +func (ps *PermissionSet) AddEnv(envID string, perms ...string) { + if envID == "" { + ps.AddGlobal(perms...) + return + } + if ps.PerEnv == nil { + ps.PerEnv = make(map[string]map[string]struct{}) + } + env, ok := ps.PerEnv[envID] + if !ok { + env = make(map[string]struct{}) + ps.PerEnv[envID] = env + } + for _, p := range perms { + env[p] = struct{}{} + } +} + +// EnvIDFromPath extracts the environment ID from a Huma operation path of the +// form /environments/{id}/... Returns "" for paths without an env segment. +// Tolerates a leading /api prefix for safety, though the Huma group already +// strips it. +func EnvIDFromPath(path string) string { + path = strings.TrimPrefix(path, "/api") + if !strings.HasPrefix(path, "/environments/") { + return "" + } + rest := path[len("/environments/"):] + slash := strings.Index(rest, "/") + if slash == -1 { + // Path is just /environments/ (no trailing segment) — this is + // not an env-scoped operation, it's the env detail endpoint itself, + // which is org-level. + return "" + } + id := rest[:slash] + if id == "" { + return "" + } + return id +} diff --git a/backend/pkg/authz/permission_set_test.go b/backend/pkg/authz/permission_set_test.go new file mode 100644 index 0000000000..531daab258 --- /dev/null +++ b/backend/pkg/authz/permission_set_test.go @@ -0,0 +1,157 @@ +package authz + +import "testing" + +func TestPermissionSetAllowsGlobal(t *testing.T) { + ps := NewPermissionSet() + ps.AddGlobal(PermContainersList) + + if !ps.Allows(PermContainersList, "env-1") { + t.Fatal("global perm should apply to any env") + } + if !ps.Allows(PermContainersList, "") { + t.Fatal("global perm should apply org-level too") + } + if ps.Allows(PermContainersStart, "env-1") { + t.Fatal("unrelated perm should be denied") + } +} + +func TestPermissionSetEnvScopedDoesNotLeak(t *testing.T) { + ps := NewPermissionSet() + ps.AddEnv("env-1", PermContainersStart) + + if !ps.Allows(PermContainersStart, "env-1") { + t.Fatal("env perm should apply to its own env") + } + if ps.Allows(PermContainersStart, "env-2") { + t.Fatal("env perm must not leak to another env") + } + if ps.Allows(PermContainersStart, "") { + t.Fatal("env perm must not satisfy an org-level check") + } +} + +func TestSudoAllowsEverything(t *testing.T) { + ps := SudoPermissionSet() + if !ps.Allows(PermContainersDelete, "any-env") { + t.Fatal("sudo should allow any perm on any env") + } + if !ps.Allows(PermUsersDelete, "") { + t.Fatal("sudo should allow org-level perms") + } + if !ps.IsGlobalAdmin() { + t.Fatal("sudo should report as global admin") + } +} + +func TestEnvIDFromPath(t *testing.T) { + cases := map[string]string{ + "/environments/abc-123/containers": "abc-123", + "/environments/abc-123/containers/foo": "abc-123", + "/api/environments/abc-123/projects": "abc-123", + "/environments/abc-123": "", // org-level env detail + "/users": "", + "": "", + } + for input, want := range cases { + if got := EnvIDFromPath(input); got != want { + t.Errorf("EnvIDFromPath(%q) = %q, want %q", input, got, want) + } + } +} + +func TestIsOrgLevelAndEnvScoped(t *testing.T) { + if !IsOrgLevel(PermUsersList) { + t.Fatal("users:list should be org-level") + } + if IsEnvScoped(PermUsersList) { + t.Fatal("users:list should not be env-scoped") + } + if IsOrgLevel(PermContainersStart) { + t.Fatal("containers:start should not be org-level") + } + if !IsEnvScoped(PermContainersStart) { + t.Fatal("containers:start should be env-scoped") + } +} + +func TestIsKnownPermissionRejectsSyntheticPrefixMatches(t *testing.T) { + // Synthetic permissions whose prefix matches a known env-scoped family + // must not be accepted — otherwise an admin could inflate ps.Global past + // TotalPermissionsCount() with bogus entries and trip IsGlobalAdmin(). + for _, p := range []string{"containers:fake1", "projects:bogus", "images:made-up"} { + if IsKnownPermission(p) { + t.Errorf("IsKnownPermission(%q) = true, want false", p) + } + if IsEnvScoped(p) { + t.Errorf("IsEnvScoped(%q) = true, want false", p) + } + } +} + +func TestBuiltInRolesOnlyReferenceKnownPermissions(t *testing.T) { + for _, p := range BuiltInEditorPermissions() { + if !IsKnownPermission(p) { + t.Errorf("editor references unknown perm %q", p) + } + } + for _, p := range BuiltInDeployerPermissions() { + if !IsKnownPermission(p) { + t.Errorf("deployer references unknown perm %q", p) + } + } + for _, p := range BuiltInViewerPermissions() { + if !IsKnownPermission(p) { + t.Errorf("viewer references unknown perm %q", p) + } + } +} + +func TestPermissionCatalogDerivesKnownPermissionsAndScopes(t *testing.T) { + catalog := PermissionCatalog() + if len(catalog) == 0 { + t.Fatal("permission catalog must not be empty") + } + + all := AllPermissions() + if len(all) != TotalPermissionsCount() { + t.Fatalf("AllPermissions length = %d, TotalPermissionsCount = %d", len(all), TotalPermissionsCount()) + } + + seen := make(map[string]struct{}, len(all)) + var catalogCount int + for _, resource := range catalog { + if resource.Scope != PermissionScopeGlobal && resource.Scope != PermissionScopeEnv { + t.Fatalf("resource %q has invalid scope %q", resource.Key, resource.Scope) + } + for _, action := range resource.Actions { + catalogCount++ + if action.Permission == "" { + t.Fatalf("resource %q action %q has empty permission", resource.Key, action.Key) + } + if _, exists := seen[action.Permission]; exists { + t.Fatalf("duplicate permission %q in catalog", action.Permission) + } + seen[action.Permission] = struct{}{} + if !IsKnownPermission(action.Permission) { + t.Fatalf("catalog permission %q is not known", action.Permission) + } + if resource.Scope == PermissionScopeGlobal && !IsOrgLevel(action.Permission) { + t.Fatalf("catalog permission %q should be org-level", action.Permission) + } + if resource.Scope == PermissionScopeEnv && !IsEnvScoped(action.Permission) { + t.Fatalf("catalog permission %q should be env-scoped", action.Permission) + } + } + } + + if catalogCount != len(all) { + t.Fatalf("catalog permission count = %d, AllPermissions count = %d", catalogCount, len(all)) + } + for _, permission := range all { + if _, exists := seen[permission]; !exists { + t.Fatalf("AllPermissions includes %q outside catalog", permission) + } + } +} diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go new file mode 100644 index 0000000000..5cd372a040 --- /dev/null +++ b/backend/pkg/authz/permissions.go @@ -0,0 +1,362 @@ +// Package authz defines the permission taxonomy and authorization primitives +// used across Arcane handlers. Permissions are strings of the form +// ":" and are classified as either org-level (require a +// globally-scoped role) or env-scoped (resolved against the environment ID +// from the request path). +package authz + +// Built-in role IDs. Stable across migrations so other code may reference them +// safely. All six built-in roles are seeded by migration 054_add_rbac. +const ( + BuiltInRoleAdmin = "role_admin" + BuiltInRoleEditor = "role_editor" + BuiltInRoleNoShellEditor = "role_no_shell_editor" + BuiltInRoleDeployer = "role_deployer" + BuiltInRoleMonitor = "role_monitor" + BuiltInRoleViewer = "role_viewer" +) + +// Org-level permissions (require a global-scope role assignment). +const ( + PermUsersList = "users:list" + PermUsersRead = "users:read" + PermUsersCreate = "users:create" + PermUsersUpdate = "users:update" + PermUsersDelete = "users:delete" + + // Role management (Create / Update / Delete) and role assignment to users + // are reserved for global admins and intentionally not exposed as delegated + // permissions — see backend/api/middleware/role.go::RequireGlobalAdmin. + // Likewise, managing OIDC group → role mappings is admin-only because it + // is effectively another path for granting role assignments. + PermRolesList = "roles:list" + PermRolesRead = "roles:read" + + PermApiKeysList = "apikeys:list" + PermApiKeysRead = "apikeys:read" + PermApiKeysCreate = "apikeys:create" + PermApiKeysUpdate = "apikeys:update" + PermApiKeysDelete = "apikeys:delete" + + PermSettingsRead = "settings:read" + PermSettingsWrite = "settings:write" + + PermEnvironmentsList = "environments:list" + PermEnvironmentsRead = "environments:read" + PermEnvironmentsCreate = "environments:create" + PermEnvironmentsUpdate = "environments:update" + PermEnvironmentsDelete = "environments:delete" + PermEnvironmentsPair = "environments:pair" + PermEnvironmentsSync = "environments:sync" + + PermRegistriesList = "registries:list" + PermRegistriesRead = "registries:read" + PermRegistriesCreate = "registries:create" + PermRegistriesUpdate = "registries:update" + PermRegistriesDelete = "registries:delete" + PermRegistriesTest = "registries:test" + + PermTemplatesList = "templates:list" + PermTemplatesRead = "templates:read" + PermTemplatesCreate = "templates:create" + PermTemplatesUpdate = "templates:update" + PermTemplatesDelete = "templates:delete" + + PermGitReposList = "git-repositories:list" + PermGitReposRead = "git-repositories:read" + PermGitReposCreate = "git-repositories:create" + PermGitReposUpdate = "git-repositories:update" + PermGitReposDelete = "git-repositories:delete" + PermGitReposTest = "git-repositories:test" + PermGitReposSync = "git-repositories:sync" + + PermEventsRead = "events:read" + PermCustomizeManage = "customize:manage" +) + +// Env-scoped permissions (resolved against the {id} env ID in the path). +const ( + PermContainersList = "containers:list" + PermContainersRead = "containers:read" + PermContainersLogs = "containers:logs" + PermContainersCreate = "containers:create" + PermContainersStart = "containers:start" + PermContainersStop = "containers:stop" + PermContainersRestart = "containers:restart" + PermContainersRedeploy = "containers:redeploy" + PermContainersDelete = "containers:delete" + PermContainersExec = "containers:exec" + PermContainersAutoUpdate = "containers:autoupdate" + + PermProjectsList = "projects:list" + PermProjectsRead = "projects:read" + PermProjectsLogs = "projects:logs" + PermProjectsCreate = "projects:create" + PermProjectsUpdate = "projects:update" + PermProjectsDeploy = "projects:deploy" + PermProjectsDown = "projects:down" + PermProjectsRestart = "projects:restart" + PermProjectsDelete = "projects:delete" + PermProjectsArchive = "projects:archive" + + PermImagesList = "images:list" + PermImagesRead = "images:read" + PermImagesPull = "images:pull" + PermImagesPush = "images:push" + PermImagesBuild = "images:build" + PermImagesPrune = "images:prune" + PermImagesDelete = "images:delete" + PermImagesUpload = "images:upload" + + PermVolumesList = "volumes:list" + PermVolumesRead = "volumes:read" + PermVolumesCreate = "volumes:create" + PermVolumesDelete = "volumes:delete" + PermVolumesPrune = "volumes:prune" + PermVolumesBrowse = "volumes:browse" + PermVolumesUpload = "volumes:upload" + PermVolumesBackup = "volumes:backup" + + PermNetworksList = "networks:list" + PermNetworksRead = "networks:read" + PermNetworksCreate = "networks:create" + PermNetworksDelete = "networks:delete" + PermNetworksPrune = "networks:prune" + + PermSwarmRead = "swarm:read" + PermSwarmInit = "swarm:init" + PermSwarmJoin = "swarm:join" + PermSwarmLeave = "swarm:leave" + PermSwarmSpec = "swarm:spec" + PermSwarmNodes = "swarm:nodes" + PermSwarmServices = "swarm:services" + PermSwarmServicesLogs = "swarm:services:logs" + PermSwarmStacks = "swarm:stacks" + PermSwarmConfigs = "swarm:configs" + PermSwarmSecrets = "swarm:secrets" + PermSwarmUnlock = "swarm:unlock" + + PermGitOpsList = "gitops:list" + PermGitOpsRead = "gitops:read" + PermGitOpsCreate = "gitops:create" + PermGitOpsUpdate = "gitops:update" + PermGitOpsDelete = "gitops:delete" + PermGitOpsSync = "gitops:sync" + + PermWebhooksList = "webhooks:list" + PermWebhooksCreate = "webhooks:create" + PermWebhooksUpdate = "webhooks:update" + PermWebhooksDelete = "webhooks:delete" + + PermJobsManage = "jobs:manage" + PermNotificationsManage = "notifications:manage" + PermDashboardRead = "dashboard:read" + + PermSystemRead = "system:read" + PermSystemPrune = "system:prune" + PermSystemUpgrade = "system:upgrade" + + PermImageUpdatesRead = "image-updates:read" + PermImageUpdatesCheck = "image-updates:check" + + PermVulnsRead = "vulnerabilities:read" + PermVulnsScan = "vulnerabilities:scan" + PermVulnsManage = "vulnerabilities:manage" + + PermBuildWorkspacesManage = "build-workspaces:manage" +) + +// orgLevelPermissions is derived from the authz permission catalog. Env-scoped +// permissions are everything in the catalog that is not in this set. +var orgLevelPermissions = buildOrgLevelPermissionsInternal() + +func buildOrgLevelPermissionsInternal() map[string]struct{} { + out := make(map[string]struct{}) + for _, resource := range permissionCatalog { + if resource.Scope != PermissionScopeGlobal { + continue + } + for _, action := range resource.Actions { + out[action.Permission] = struct{}{} + } + } + return out +} + +// IsOrgLevel reports whether the given permission requires a globally-scoped +// role assignment (and applies only to org-level endpoints). +func IsOrgLevel(perm string) bool { + _, ok := orgLevelPermissions[perm] + return ok +} + +// IsEnvScoped reports whether the given permission is resolved against an +// environment ID. Returns false for permissions not in AllPermissions(). +func IsEnvScoped(perm string) bool { + if _, ok := allPermissionsSet[perm]; !ok { + return false + } + return !IsOrgLevel(perm) +} + +// allPermissionsSet is the canonical exact-set lookup of every defined +// permission, built once from AllPermissions(). Anchors IsKnownPermission and +// IsEnvScoped to exact membership so PermissionSet.IsGlobalAdmin can rely on +// "ps.Global ⊆ allPermissionsSet" as a true invariant. +var allPermissionsSet = func() map[string]struct{} { + all := AllPermissions() + m := make(map[string]struct{}, len(all)) + for _, p := range all { + m[p] = struct{}{} + } + return m +}() + +// totalPermissionsCount caches len(allPermissionsSet) so callers on the auth +// hot path (notably PermissionSet.IsGlobalAdmin) don't re-allocate and walk a +// ~100-element slice on every authenticated request. +var totalPermissionsCount = len(allPermissionsSet) + +// TotalPermissionsCount returns the number of distinct permission constants +// the package defines. Computed once at init; cheap to call repeatedly. +func TotalPermissionsCount() int { return totalPermissionsCount } + +// AllPermissions returns every recognized permission constant. Used to seed +// the Admin built-in role and to validate role definitions. +func AllPermissions() []string { + var count int + for _, resource := range permissionCatalog { + count += len(resource.Actions) + } + out := make([]string, 0, count) + for _, resource := range permissionCatalog { + for _, action := range resource.Actions { + out = append(out, action.Permission) + } + } + return out +} + +// IsKnownPermission reports whether perm matches any defined permission +// constant. Used to reject role definitions referencing unknown permissions. +func IsKnownPermission(perm string) bool { + _, ok := allPermissionsSet[perm] + return ok +} + +// BuiltInEditorPermissions returns the permission set for the Editor built-in +// role: read+write on Docker resources and read on most org-level resources. +// Excludes user/role/key management and settings writes. +func BuiltInEditorPermissions() []string { + return []string{ + // Read on org-level + PermUsersList, PermUsersRead, + PermRolesList, PermRolesRead, + PermApiKeysList, PermApiKeysRead, + PermSettingsRead, + PermEnvironmentsList, PermEnvironmentsRead, PermEnvironmentsSync, + PermRegistriesList, PermRegistriesRead, + PermTemplatesList, PermTemplatesRead, PermTemplatesCreate, PermTemplatesUpdate, PermTemplatesDelete, + PermGitReposList, PermGitReposRead, + PermEventsRead, + // Full env-scoped Docker management + PermContainersList, PermContainersRead, PermContainersLogs, PermContainersCreate, PermContainersStart, PermContainersStop, PermContainersRestart, PermContainersRedeploy, PermContainersDelete, PermContainersExec, PermContainersAutoUpdate, + PermProjectsList, PermProjectsRead, PermProjectsLogs, PermProjectsCreate, PermProjectsUpdate, PermProjectsDeploy, PermProjectsDown, PermProjectsRestart, PermProjectsDelete, PermProjectsArchive, + PermImagesList, PermImagesRead, PermImagesPull, PermImagesPush, PermImagesBuild, PermImagesPrune, PermImagesDelete, PermImagesUpload, + PermVolumesList, PermVolumesRead, PermVolumesCreate, PermVolumesDelete, PermVolumesPrune, PermVolumesBrowse, PermVolumesUpload, PermVolumesBackup, + PermNetworksList, PermNetworksRead, PermNetworksCreate, PermNetworksDelete, PermNetworksPrune, + PermSwarmRead, PermSwarmSpec, PermSwarmNodes, PermSwarmServices, PermSwarmServicesLogs, PermSwarmStacks, PermSwarmConfigs, PermSwarmSecrets, + PermGitOpsList, PermGitOpsRead, PermGitOpsCreate, PermGitOpsUpdate, PermGitOpsDelete, PermGitOpsSync, + PermWebhooksList, PermWebhooksCreate, PermWebhooksUpdate, PermWebhooksDelete, + PermJobsManage, PermNotificationsManage, PermDashboardRead, + PermSystemRead, PermSystemPrune, + PermImageUpdatesRead, PermImageUpdatesCheck, + PermVulnsRead, PermVulnsScan, PermVulnsManage, + PermBuildWorkspacesManage, + } +} + +// BuiltInDeployerPermissions returns the permission set for the Deployer +// built-in role: container/project lifecycle and read-only on everything else. +// Cannot create or delete resources; cannot manage settings/users/roles/keys. +func BuiltInDeployerPermissions() []string { + return []string{ + PermEnvironmentsList, PermEnvironmentsRead, + PermRegistriesList, PermRegistriesRead, + PermTemplatesList, PermTemplatesRead, + PermEventsRead, + PermContainersList, PermContainersRead, PermContainersLogs, PermContainersStart, PermContainersStop, PermContainersRestart, PermContainersRedeploy, + PermProjectsList, PermProjectsRead, PermProjectsLogs, PermProjectsDeploy, PermProjectsDown, PermProjectsRestart, + PermImagesList, PermImagesRead, PermImagesPull, + PermVolumesList, PermVolumesRead, PermVolumesBrowse, + PermNetworksList, PermNetworksRead, + PermSwarmRead, PermSwarmServicesLogs, + PermGitOpsList, PermGitOpsRead, PermGitOpsSync, + PermDashboardRead, + PermSystemRead, + PermImageUpdatesRead, PermImageUpdatesCheck, + PermVulnsRead, + } +} + +// BuiltInViewerPermissions returns the permission set for the Viewer built-in +// role: read-only access across every resource. +func BuiltInViewerPermissions() []string { + return []string{ + PermUsersList, PermUsersRead, + PermRolesList, PermRolesRead, + PermApiKeysList, PermApiKeysRead, + PermSettingsRead, + PermEnvironmentsList, PermEnvironmentsRead, + PermRegistriesList, PermRegistriesRead, + PermTemplatesList, PermTemplatesRead, + PermGitReposList, PermGitReposRead, + PermEventsRead, + PermContainersList, PermContainersRead, PermContainersLogs, + PermProjectsList, PermProjectsRead, PermProjectsLogs, + PermImagesList, PermImagesRead, + PermVolumesList, PermVolumesRead, PermVolumesBrowse, + PermNetworksList, PermNetworksRead, + PermSwarmRead, PermSwarmServicesLogs, + PermGitOpsList, PermGitOpsRead, + PermWebhooksList, + PermDashboardRead, + PermSystemRead, + PermImageUpdatesRead, + PermVulnsRead, + } +} + +// BuiltInMonitorPermissions returns the permission set for the Monitor +// built-in role: observability-only access — read Docker resources, view logs, +// dashboards, and events. No mutations, no exec, no user/role/settings access. +func BuiltInMonitorPermissions() []string { + return []string{ + PermEnvironmentsList, PermEnvironmentsRead, + PermEventsRead, + PermContainersList, PermContainersRead, PermContainersLogs, + PermProjectsList, PermProjectsRead, PermProjectsLogs, + PermImagesList, PermImagesRead, + PermVolumesList, PermVolumesRead, + PermNetworksList, PermNetworksRead, + PermSwarmRead, PermSwarmServicesLogs, + PermDashboardRead, + PermSystemRead, + PermImageUpdatesRead, + PermVulnsRead, + } +} + +// BuiltInNoShellEditorPermissions returns the Editor permission set minus +// PermContainersExec. For teams that want full Docker management but no +// interactive shell access into running containers. +func BuiltInNoShellEditorPermissions() []string { + src := BuiltInEditorPermissions() + out := make([]string, 0, len(src)) + for _, p := range src { + if p != PermContainersExec { + out = append(out, p) + } + } + return out +} diff --git a/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go b/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go index 4f74249805..eba05fd770 100644 --- a/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go +++ b/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: tunnel/v1/tunnel.proto diff --git a/backend/pkg/libarcane/startup/startup.go b/backend/pkg/libarcane/startup/startup.go index 3c246812df..f909e330fd 100644 --- a/backend/pkg/libarcane/startup/startup.go +++ b/backend/pkg/libarcane/startup/startup.go @@ -77,6 +77,7 @@ func TestDockerConnection(ctx context.Context, testFunc func(context.Context) er func InitializeNonAgentFeatures( ctx context.Context, cfg *RuntimeConfig, + ensureBuiltInRolesFunc func(context.Context) error, createAdminFunc func(context.Context) error, reconcileDefaultAdminAPIKeyFunc func(context.Context) error, autoLoginInitFunc func(context.Context) error, @@ -85,6 +86,12 @@ func InitializeNonAgentFeatures( return } + if ensureBuiltInRolesFunc != nil { + if err := ensureBuiltInRolesFunc(ctx); err != nil { + slog.ErrorContext(ctx, "Failed to seed built-in roles before admin bootstrap", "error", err.Error()) + } + } + if err := createAdminFunc(ctx); err != nil { slog.WarnContext(ctx, "Failed to create default admin user", "error", err.Error()) } diff --git a/backend/pkg/libarcane/startup/startup_test.go b/backend/pkg/libarcane/startup/startup_test.go index 4be6e8aaf0..96460c4659 100644 --- a/backend/pkg/libarcane/startup/startup_test.go +++ b/backend/pkg/libarcane/startup/startup_test.go @@ -259,7 +259,7 @@ func TestInitializeNonAgentFeatures(t *testing.T) { return nil } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, nil) + InitializeNonAgentFeatures(ctx, cfg, nil, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, nil) assert.False(t, createAdminCalled) assert.False(t, reconcileDefaultAdminAPIKeyCalled) @@ -284,7 +284,7 @@ func TestInitializeNonAgentFeatures(t *testing.T) { return nil } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) + InitializeNonAgentFeatures(ctx, cfg, nil, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) assert.True(t, createAdminCalled) assert.True(t, reconcileDefaultAdminAPIKeyCalled) @@ -303,6 +303,6 @@ func TestInitializeNonAgentFeatures(t *testing.T) { return errors.New("admin creation failed") } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) + InitializeNonAgentFeatures(ctx, cfg, nil, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) }) } diff --git a/backend/pkg/utils/auth.go b/backend/pkg/utils/auth.go index 7a60a6a6bc..afed778466 100644 --- a/backend/pkg/utils/auth.go +++ b/backend/pkg/utils/auth.go @@ -1,7 +1,5 @@ package utils -import "slices" - // Auth header names and path prefixes shared between the Echo middleware // (WebSocket/diagnostics) and the Huma auth bridge (REST). Keep these in one // place so a change to a header name applies to every route type at once. @@ -11,8 +9,3 @@ const ( HeaderApiKey = "X-API-Key" // #nosec G101: header name, not a credential AgentPairingPrefix = "/api/environments/0/agent/pair" ) - -// UserHasRole reports whether the user's roles contains the given role. -func UserHasRole(roles []string, role string) bool { - return slices.Contains(roles, role) -} diff --git a/backend/pkg/utils/cache/ttl.go b/backend/pkg/utils/cache/ttl.go index 1b5011422d..0dd8b4338e 100644 --- a/backend/pkg/utils/cache/ttl.go +++ b/backend/pkg/utils/cache/ttl.go @@ -64,3 +64,10 @@ func (c *TTL[V]) DeleteFunc(fn func(key string, value V) bool) { } } } + +// Delete removes the entry for key, if present. +func (c *TTL[V]) Delete(key string) { + c.mu.Lock() + delete(c.entries, key) + c.mu.Unlock() +} diff --git a/backend/pkg/utils/jwtclaims/jwt.go b/backend/pkg/utils/jwtclaims/jwt.go index 9b0bb992f9..493c9c970f 100644 --- a/backend/pkg/utils/jwtclaims/jwt.go +++ b/backend/pkg/utils/jwtclaims/jwt.go @@ -135,36 +135,3 @@ func GetByPath(m map[string]any, path string) (any, bool) { } return cur, true } - -// EvalMatch checks if a claim matches any of the desired values -func EvalMatch(v any, want []string) bool { - if len(want) == 0 { - if b, ok := v.(bool); ok { - return b - } - return false - } - wantSet := map[string]struct{}{} - for _, s := range want { - wantSet[strings.ToLower(s)] = struct{}{} - } - switch x := v.(type) { - case string: - _, ok := wantSet[strings.ToLower(x)] - return ok - case []any: - for _, it := range x { - if s, ok := it.(string); ok { - if _, ok2 := wantSet[strings.ToLower(s)]; ok2 { - return true - } - } - } - return false - case bool: - _, ok := wantSet[strings.ToLower(fmt.Sprintf("%v", x))] - return ok - default: - return false - } -} diff --git a/backend/resources/migrations/postgres/054_add_rbac.down.sql b/backend/resources/migrations/postgres/054_add_rbac.down.sql new file mode 100644 index 0000000000..0bc7893ed5 --- /dev/null +++ b/backend/resources/migrations/postgres/054_add_rbac.down.sql @@ -0,0 +1,17 @@ + +DELETE FROM settings WHERE key = 'oidcGroupsClaim'; + +DROP INDEX IF EXISTS idx_orm_claim; +DROP TABLE IF EXISTS oidc_role_mappings; + +DROP INDEX IF EXISTS idx_akp_uniq; +DROP INDEX IF EXISTS idx_akp_key; +DROP TABLE IF EXISTS api_key_permissions; + +DROP INDEX IF EXISTS idx_ura_uniq; +DROP INDEX IF EXISTS idx_ura_env; +DROP INDEX IF EXISTS idx_ura_role; +DROP INDEX IF EXISTS idx_ura_user; +DROP TABLE IF EXISTS user_role_assignments; + +DROP TABLE IF EXISTS roles; diff --git a/backend/resources/migrations/postgres/054_add_rbac.up.sql b/backend/resources/migrations/postgres/054_add_rbac.up.sql new file mode 100644 index 0000000000..811d56bfb9 --- /dev/null +++ b/backend/resources/migrations/postgres/054_add_rbac.up.sql @@ -0,0 +1,53 @@ + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + permissions JSONB NOT NULL DEFAULT '[]', + built_in BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + environment_id TEXT REFERENCES environments(id) ON DELETE CASCADE, + source TEXT NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_ura_user ON user_role_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_ura_role ON user_role_assignments(role_id); +CREATE INDEX IF NOT EXISTS idx_ura_env ON user_role_assignments(environment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_ura_uniq + ON user_role_assignments(user_id, role_id, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS api_key_permissions ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + environment_id TEXT REFERENCES environments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_akp_key ON api_key_permissions(api_key_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq + ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS oidc_role_mappings ( + id TEXT PRIMARY KEY, + claim_value TEXT NOT NULL, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + environment_id TEXT REFERENCES environments(id) ON DELETE CASCADE, + -- 'manual' = created via UI/API; 'env' = declared via OIDC_ROLE_MAPPINGS + -- env var and reconciled at boot. The API refuses mutations on 'env' rows. + source TEXT NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_orm_claim ON oidc_role_mappings(claim_value); diff --git a/backend/resources/migrations/sqlite/054_add_rbac.down.sql b/backend/resources/migrations/sqlite/054_add_rbac.down.sql new file mode 100644 index 0000000000..0bc7893ed5 --- /dev/null +++ b/backend/resources/migrations/sqlite/054_add_rbac.down.sql @@ -0,0 +1,17 @@ + +DELETE FROM settings WHERE key = 'oidcGroupsClaim'; + +DROP INDEX IF EXISTS idx_orm_claim; +DROP TABLE IF EXISTS oidc_role_mappings; + +DROP INDEX IF EXISTS idx_akp_uniq; +DROP INDEX IF EXISTS idx_akp_key; +DROP TABLE IF EXISTS api_key_permissions; + +DROP INDEX IF EXISTS idx_ura_uniq; +DROP INDEX IF EXISTS idx_ura_env; +DROP INDEX IF EXISTS idx_ura_role; +DROP INDEX IF EXISTS idx_ura_user; +DROP TABLE IF EXISTS user_role_assignments; + +DROP TABLE IF EXISTS roles; diff --git a/backend/resources/migrations/sqlite/054_add_rbac.up.sql b/backend/resources/migrations/sqlite/054_add_rbac.up.sql new file mode 100644 index 0000000000..0428fc398e --- /dev/null +++ b/backend/resources/migrations/sqlite/054_add_rbac.up.sql @@ -0,0 +1,60 @@ + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + permissions TEXT NOT NULL DEFAULT '[]', + built_in BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME +); + +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + environment_id TEXT, + source TEXT NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_ura_user ON user_role_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_ura_role ON user_role_assignments(role_id); +CREATE INDEX IF NOT EXISTS idx_ura_env ON user_role_assignments(environment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_ura_uniq + ON user_role_assignments(user_id, role_id, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS api_key_permissions ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + permission TEXT NOT NULL, + environment_id TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE, + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_akp_key ON api_key_permissions(api_key_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq + ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS oidc_role_mappings ( + id TEXT PRIMARY KEY, + claim_value TEXT NOT NULL, + role_id TEXT NOT NULL, + environment_id TEXT, + -- 'manual' = created via UI/API; 'env' = declared via OIDC_ROLE_MAPPINGS + -- env var and reconciled at boot. The API refuses mutations on 'env' rows. + source TEXT NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_orm_claim ON oidc_role_mappings(claim_value); diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index a8b2730840..3aa235b056 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -24,8 +24,18 @@ type ArcaneApiEndpoints struct { ApiKeyEndpoint string // Users - UsersEndpoint string - UserEndpoint string + UsersEndpoint string + UserEndpoint string + UserRoleAssignmentsEndpoint string + + // Roles (RBAC) + RolesEndpoint string + RoleEndpoint string + RolesAvailablePermissionsEndpoint string + + // OIDC role mappings + OidcRoleMappingsEndpoint string + OidcRoleMappingEndpoint string // Environments EnvironmentsEndpoint string @@ -185,8 +195,18 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut ApiKeyEndpoint: "/api/api-keys/%s", // Users - UsersEndpoint: "/api/users", - UserEndpoint: "/api/users/%s", + UsersEndpoint: "/api/users", + UserEndpoint: "/api/users/%s", + UserRoleAssignmentsEndpoint: "/api/users/%s/role-assignments", + + // Roles (RBAC) + RolesEndpoint: "/api/roles", + RoleEndpoint: "/api/roles/%s", + RolesAvailablePermissionsEndpoint: "/api/roles/available-permissions", + + // OIDC role mappings + OidcRoleMappingsEndpoint: "/api/oidc/role-mappings", + OidcRoleMappingEndpoint: "/api/oidc/role-mappings/%s", // Environments EnvironmentsEndpoint: "/api/environments", @@ -343,6 +363,22 @@ func (e ArcaneApiEndpoints) ApiKey(id string) string { return fmt.Sprintf(e.ApiK // User endpoints func (e ArcaneApiEndpoints) Users() string { return e.UsersEndpoint } func (e ArcaneApiEndpoints) User(id string) string { return fmt.Sprintf(e.UserEndpoint, id) } +func (e ArcaneApiEndpoints) UserRoleAssignments(userID string) string { + return fmt.Sprintf(e.UserRoleAssignmentsEndpoint, userID) +} + +// Role (RBAC) endpoints +func (e ArcaneApiEndpoints) Roles() string { return e.RolesEndpoint } +func (e ArcaneApiEndpoints) Role(id string) string { return fmt.Sprintf(e.RoleEndpoint, id) } +func (e ArcaneApiEndpoints) RolesAvailablePermissions() string { + return e.RolesAvailablePermissionsEndpoint +} + +// OIDC role mapping endpoints +func (e ArcaneApiEndpoints) OidcRoleMappings() string { return e.OidcRoleMappingsEndpoint } +func (e ArcaneApiEndpoints) OidcRoleMapping(id string) string { + return fmt.Sprintf(e.OidcRoleMappingEndpoint, id) +} // Environment endpoints func (e ArcaneApiEndpoints) Environments() string { return e.EnvironmentsEndpoint } diff --git a/cli/pkg/admin/apikeys/cmd.go b/cli/pkg/admin/apikeys/cmd.go index dab8200e6c..534041727f 100644 --- a/cli/pkg/admin/apikeys/cmd.go +++ b/cli/pkg/admin/apikeys/cmd.go @@ -1,11 +1,10 @@ package apikeys import ( - "encoding/json" "fmt" + "strings" "time" - "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" "github.com/getarcaneapp/arcane/cli/internal/output" "github.com/getarcaneapp/arcane/cli/internal/types" @@ -21,12 +20,48 @@ var ( jsonOutput bool ) +var ( + apikeyCreatePermissions []string +) + var ( apikeyUpdateName string apikeyUpdateDescription string apikeyUpdateExpiresAt string + apikeyUpdatePermissions []string ) +// parsePermissionGrantsInternal turns `--permission` tokens into the wire-format +// grant list. Each token is either `resource:action` (global grant) or +// `resource:action:envId` (env-scoped grant). Anything else is an error so the +// user catches typos before the server rejects them. +func parsePermissionGrantsInternal(tokens []string) ([]apikey.PermissionGrant, error) { + out := make([]apikey.PermissionGrant, 0, len(tokens)) + for _, raw := range tokens { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + parts := strings.Split(token, ":") + switch len(parts) { + case 2: + out = append(out, apikey.PermissionGrant{Permission: token}) + case 3: + env := parts[2] + if parts[0] == "" || parts[1] == "" || env == "" { + return nil, fmt.Errorf("invalid --permission value %q (expected `resource:action[:envId]`)", raw) + } + out = append(out, apikey.PermissionGrant{ + Permission: parts[0] + ":" + parts[1], + EnvironmentID: &env, + }) + default: + return nil, fmt.Errorf("invalid --permission value %q (expected `resource:action[:envId]`)", raw) + } + } + return out, nil +} + // ApiKeysCmd is the parent command for API key operations var ApiKeysCmd = &cobra.Command{ Use: "api-keys", @@ -40,7 +75,7 @@ var listCmd = &cobra.Command{ Short: "List API keys", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -58,20 +93,15 @@ var listCmd = &cobra.Command{ defer func() { _ = resp.Body.Close() }() var result base.Paginated[apikey.ApiKey] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list API keys: %w", err) } if jsonOutput { - resultBytes, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result) } - headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED", "LAST USED"} + headers := []string{"ID", "NAME", "DESCRIPTION", "PERMISSIONS", "CREATED", "LAST USED"} rows := make([][]string, len(result.Data)) for i, key := range result.Data { description := "" @@ -86,6 +116,7 @@ var listCmd = &cobra.Command{ key.ID, key.Name, description, + fmt.Sprintf("%d", len(key.Permissions)), key.CreatedAt.Format("2006-01-02 15:04"), lastUsed, } @@ -105,51 +136,46 @@ var createCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { description, _ := cmd.Flags().GetString("description") - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } + grants, err := parsePermissionGrantsInternal(apikeyCreatePermissions) + if err != nil { + return err + } + if len(grants) == 0 { + return fmt.Errorf("at least one --permission is required (e.g. --permission containers:list)") + } createReq := apikey.CreateApiKey{ - Name: args[0], + Name: args[0], + Permissions: grants, } if description != "" { createReq.Description = &description } - // TODO: Parse expiresAt string to time.Time if needed - // if expiresAt != "" { - // parsedTime, err := time.Parse(time.RFC3339, expiresAt) - // if err == nil { - // createReq.ExpiresAt = &parsedTime - // } - // } - - reqBody, err := json.Marshal(createReq) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) + if expiresAtRaw, _ := cmd.Flags().GetString("expires-at"); expiresAtRaw != "" { + parsed, err := time.Parse(time.RFC3339, expiresAtRaw) + if err != nil { + return fmt.Errorf("invalid --expires-at format (use RFC3339): %w", err) + } + createReq.ExpiresAt = &parsed } - resp, err := c.Post(cmd.Context(), types.Endpoints.ApiKeys(), reqBody) + resp, err := c.Post(cmd.Context(), types.Endpoints.ApiKeys(), createReq) if err != nil { return fmt.Errorf("failed to create API key: %w", err) } defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to create API key: %w", err) - } var result base.ApiResponse[apikey.ApiKeyCreatedDto] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to create API key: %w", err) } if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result.Data) } output.Success("API key created successfully") @@ -181,7 +207,7 @@ var deleteCmd = &cobra.Command{ } } - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -197,15 +223,10 @@ var deleteCmd = &cobra.Command{ if jsonOutput { var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to delete API key: %w", err) } - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result.Data) } output.Success("API key deleted successfully") @@ -219,7 +240,7 @@ var getCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -231,17 +252,12 @@ var getCmd = &cobra.Command{ defer func() { _ = resp.Body.Close() }() var result base.ApiResponse[apikey.ApiKey] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to get API key: %w", err) } if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result.Data) } output.Header("API Key Details") @@ -258,6 +274,18 @@ var getCmd = &cobra.Command{ if result.Data.ExpiresAt != nil { output.KeyValue("Expires", result.Data.ExpiresAt.Format("2006-01-02 15:04")) } + output.KeyValue("Permissions", fmt.Sprintf("%d", len(result.Data.Permissions))) + if len(result.Data.Permissions) > 0 { + rows := make([][]string, len(result.Data.Permissions)) + for i, g := range result.Data.Permissions { + scope := "global" + if g.EnvironmentID != nil { + scope = *g.EnvironmentID + } + rows[i] = []string{g.Permission, scope} + } + output.Table([]string{"PERMISSION", "SCOPE"}, rows) + } return nil }, } @@ -268,7 +296,7 @@ var updateCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -287,6 +315,15 @@ var updateCmd = &cobra.Command{ } req.ExpiresAt = &parsedTime } + if cmd.Flags().Changed("permission") { + grants, err := parsePermissionGrantsInternal(apikeyUpdatePermissions) + if err != nil { + return err + } + // Allow `--permission ""` once to clear all grants. Otherwise + // the parsed slice replaces the key's permission set entirely. + req.Permissions = grants + } resp, err := c.Put(cmd.Context(), types.Endpoints.ApiKey(args[0]), req) if err != nil { @@ -299,12 +336,10 @@ var updateCmd = &cobra.Command{ if jsonOutput { var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { - if resultBytes, err := json.MarshalIndent(result.Data, "", " "); err == nil { - fmt.Println(string(resultBytes)) - } + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to update API key: %w", err) } - return nil + return cmdutil.PrintJSON(result.Data) } output.Success("API key updated successfully") @@ -326,8 +361,11 @@ func init() { // Create command flags createCmd.Flags().StringP("description", "d", "", "Description for the API key") - createCmd.Flags().String("expires-at", "", "Expiration date (ISO 8601 format)") + createCmd.Flags().String("expires-at", "", "Expiration date (RFC3339 format)") + createCmd.Flags().StringArrayVarP(&apikeyCreatePermissions, "permission", "p", nil, + "Permission to grant as `resource:action[:envId]` (repeatable, required). Omit envId for a global grant. Cannot exceed your own permissions.") createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + _ = createCmd.MarkFlagRequired("permission") // Get command flags getCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") @@ -336,6 +374,8 @@ func init() { updateCmd.Flags().StringVar(&apikeyUpdateName, "name", "", "API key name") updateCmd.Flags().StringVarP(&apikeyUpdateDescription, "description", "d", "", "API key description") updateCmd.Flags().StringVar(&apikeyUpdateExpiresAt, "expires-at", "", "Expiration date (RFC3339 format)") + updateCmd.Flags().StringArrayVarP(&apikeyUpdatePermissions, "permission", "p", nil, + "Replace the key's permission grants. Use `resource:action[:envId]` (repeatable). Pass --permission \"\" once to clear all grants.") updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") // Delete command flags diff --git a/cli/pkg/admin/cmd.go b/cli/pkg/admin/cmd.go index c671a04518..43339b73ec 100644 --- a/cli/pkg/admin/cmd.go +++ b/cli/pkg/admin/cmd.go @@ -4,6 +4,8 @@ import ( "github.com/getarcaneapp/arcane/cli/pkg/admin/apikeys" "github.com/getarcaneapp/arcane/cli/pkg/admin/events" "github.com/getarcaneapp/arcane/cli/pkg/admin/notifications" + "github.com/getarcaneapp/arcane/cli/pkg/admin/oidcmappings" + "github.com/getarcaneapp/arcane/cli/pkg/admin/roles" "github.com/getarcaneapp/arcane/cli/pkg/admin/users" "github.com/spf13/cobra" ) @@ -17,6 +19,8 @@ var AdminCmd = &cobra.Command{ func init() { AdminCmd.AddCommand(users.UsersCmd) + AdminCmd.AddCommand(roles.RolesCmd) + AdminCmd.AddCommand(oidcmappings.OidcMappingsCmd) AdminCmd.AddCommand(apikeys.ApiKeysCmd) AdminCmd.AddCommand(events.EventsCmd) AdminCmd.AddCommand(notifications.NotificationsCmd) diff --git a/cli/pkg/admin/oidcmappings/cmd.go b/cli/pkg/admin/oidcmappings/cmd.go new file mode 100644 index 0000000000..14d7895241 --- /dev/null +++ b/cli/pkg/admin/oidcmappings/cmd.go @@ -0,0 +1,257 @@ +// Package oidcmappings provides the `arcane admin oidc-mappings` command tree. +// Mappings convert OIDC group/claim values into role assignments on every +// login — they're the SSO-driven counterpart to manual role assignments. +package oidcmappings + +import ( + "fmt" + + "github.com/getarcaneapp/arcane/cli/internal/cmdutil" + "github.com/getarcaneapp/arcane/cli/internal/output" + "github.com/getarcaneapp/arcane/cli/internal/types" + "github.com/getarcaneapp/arcane/types/base" + roletypes "github.com/getarcaneapp/arcane/types/role" + "github.com/spf13/cobra" +) + +var ( + forceFlag bool + jsonOutput bool +) + +var ( + createClaim string + createRoleID string + createEnvironment string +) + +var ( + updateClaim string + updateRoleID string + updateEnvironment string +) + +// OidcMappingsCmd is the parent command for OIDC role mapping operations. +var OidcMappingsCmd = &cobra.Command{ + Use: "oidc-mappings", + Aliases: []string{"oidc-mapping", "oidc"}, + Short: "Manage OIDC group → role mappings", + Long: "Manage OIDC group → role mappings. On every OIDC login, Arcane " + + "looks up the user's groups claim and applies the matching mappings " + + "as source='oidc' role assignments. The groups claim itself is " + + "configured via the oidcGroupsClaim setting (default `groups`).", +} + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List OIDC role mappings", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.OidcRoleMappings()) + if err != nil { + return fmt.Errorf("failed to list OIDC mappings: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[[]roletypes.OidcRoleMapping] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list OIDC mappings: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + if len(result.Data) == 0 { + output.Info("No OIDC role mappings configured") + return nil + } + + headers := []string{"ID", "CLAIM VALUE", "ROLE", "SCOPE"} + rows := make([][]string, len(result.Data)) + for i, m := range result.Data { + scope := "global" + if m.EnvironmentID != nil { + scope = *m.EnvironmentID + } + rows[i] = []string{m.ID, m.ClaimValue, m.RoleID, scope} + } + output.Table(headers, rows) + return nil + }, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create an OIDC role mapping", + Example: ` arcane admin oidc-mappings create --claim docker-admins --role role_admin`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + req := roletypes.CreateOidcRoleMapping{ + ClaimValue: createClaim, + RoleID: createRoleID, + } + if cmd.Flags().Changed("environment") && createEnvironment != "" { + env := createEnvironment + req.EnvironmentID = &env + } + + resp, err := c.Post(cmd.Context(), types.Endpoints.OidcRoleMappings(), req) + if err != nil { + return fmt.Errorf("failed to create OIDC mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.OidcRoleMapping] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to create OIDC mapping: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + output.Success("OIDC mapping created") + output.KeyValue("ID", result.Data.ID) + output.KeyValue("Claim value", result.Data.ClaimValue) + output.KeyValue("Role", result.Data.RoleID) + scope := "global" + if result.Data.EnvironmentID != nil { + scope = *result.Data.EnvironmentID + } + output.KeyValue("Scope", scope) + return nil + }, +} + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an OIDC role mapping", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + // The PUT contract requires every field — fetch current and overlay. + current, err := fetchMappingInternal(cmd, args[0]) + if err != nil { + return err + } + + req := roletypes.UpdateOidcRoleMapping{ + ClaimValue: current.ClaimValue, + RoleID: current.RoleID, + EnvironmentID: current.EnvironmentID, + } + if cmd.Flags().Changed("claim") { + req.ClaimValue = updateClaim + } + if cmd.Flags().Changed("role") { + req.RoleID = updateRoleID + } + if cmd.Flags().Changed("environment") { + if updateEnvironment == "" { + // Empty value flips an env-scoped mapping back to global. + req.EnvironmentID = nil + } else { + env := updateEnvironment + req.EnvironmentID = &env + } + } + + resp, err := c.Put(cmd.Context(), types.Endpoints.OidcRoleMapping(args[0]), req) + if err != nil { + return fmt.Errorf("failed to update OIDC mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to update OIDC mapping: %w", err) + } + output.Success("OIDC mapping updated") + return nil + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm", "remove"}, + Short: "Delete an OIDC role mapping", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if !forceFlag { + confirmed, err := cmdutil.Confirm(cmd, fmt.Sprintf("Delete OIDC mapping %s?", args[0])) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled") + return nil + } + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Delete(cmd.Context(), types.Endpoints.OidcRoleMapping(args[0])) + if err != nil { + return fmt.Errorf("failed to delete OIDC mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to delete OIDC mapping: %w", err) + } + output.Success("OIDC mapping deleted") + return nil + }, +} + +func fetchMappingInternal(cmd *cobra.Command, id string) (*roletypes.OidcRoleMapping, error) { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return nil, err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.OidcRoleMapping(id)) + if err != nil { + return nil, fmt.Errorf("failed to load current mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.OidcRoleMapping] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("failed to load current mapping: %w", err) + } + return &result.Data, nil +} + +func init() { + OidcMappingsCmd.AddCommand(listCmd) + OidcMappingsCmd.AddCommand(createCmd) + OidcMappingsCmd.AddCommand(updateCmd) + OidcMappingsCmd.AddCommand(deleteCmd) + + listCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + createCmd.Flags().StringVar(&createClaim, "claim", "", "OIDC claim value (required)") + createCmd.Flags().StringVar(&createRoleID, "role", "", "Role ID to grant when the claim matches (required)") + createCmd.Flags().StringVar(&createEnvironment, "environment", "", "Scope the grant to one environment (omit for global)") + createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + _ = createCmd.MarkFlagRequired("claim") + _ = createCmd.MarkFlagRequired("role") + + updateCmd.Flags().StringVar(&updateClaim, "claim", "", "New claim value") + updateCmd.Flags().StringVar(&updateRoleID, "role", "", "New role ID") + updateCmd.Flags().StringVar(&updateEnvironment, "environment", "", "New environment scope (pass empty to make global)") + updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + deleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Force deletion without confirmation") +} diff --git a/cli/pkg/admin/roles/cmd.go b/cli/pkg/admin/roles/cmd.go new file mode 100644 index 0000000000..cb2b6662e2 --- /dev/null +++ b/cli/pkg/admin/roles/cmd.go @@ -0,0 +1,479 @@ +// Package roles provides the `arcane admin roles` command tree for RBAC. +// Roles are named permission sets that can be granted to users (globally or +// per-environment) and to API keys. Four built-in roles (Admin, Editor, +// Deployer, Viewer) ship with Arcane and cannot be modified; everything else +// is a custom role created via this command. +package roles + +import ( + "fmt" + "strings" + + "github.com/getarcaneapp/arcane/cli/internal/cmdutil" + "github.com/getarcaneapp/arcane/cli/internal/output" + "github.com/getarcaneapp/arcane/cli/internal/types" + "github.com/getarcaneapp/arcane/types/base" + roletypes "github.com/getarcaneapp/arcane/types/role" + "github.com/spf13/cobra" +) + +var ( + limitFlag int + startFlag int + forceFlag bool + jsonOutput bool +) + +var ( + roleCreateName string + roleCreateDescription string + roleCreatePermissions []string +) + +var ( + roleUpdateName string + roleUpdateDescription string + roleUpdatePermissions []string +) + +// `assign` flags. Each --role argument is parsed as `[:]`. +// Omitting the env id gives a global assignment. +var ( + assignRoles []string +) + +// RolesCmd is the parent command for role operations. +var RolesCmd = &cobra.Command{ + Use: "roles", + Aliases: []string{"role"}, + Short: "Manage roles and per-user role assignments", +} + +// ---------- Role CRUD ---------- + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List roles", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + path := types.Endpoints.Roles() + path, err = cmdutil.ApplyPaginationParams(cmd, path, "roles", "limit", limitFlag, 20, "start", startFlag) + if err != nil { + return fmt.Errorf("failed to build pagination query: %w", err) + } + + resp, err := c.Get(cmd.Context(), path) + if err != nil { + return fmt.Errorf("failed to list roles: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.Paginated[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list roles: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result) + } + + headers := []string{"ID", "NAME", "TYPE", "USERS", "PERMISSIONS"} + rows := make([][]string, len(result.Data)) + for i, r := range result.Data { + roleType := "custom" + if r.BuiltIn { + roleType = "built-in" + } + rows[i] = []string{ + r.ID, + r.Name, + roleType, + fmt.Sprintf("%d", r.AssignedUserCount), + fmt.Sprintf("%d", len(r.Permissions)), + } + } + output.Table(headers, rows) + output.Showing(len(result.Data), result.Pagination.TotalItems, "roles") + return nil + }, +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get role details", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.Role(args[0])) + if err != nil { + return fmt.Errorf("failed to get role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to get role: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + output.Header("Role Details") + output.KeyValue("ID", result.Data.ID) + output.KeyValue("Name", result.Data.Name) + if result.Data.Description != nil { + output.KeyValue("Description", *result.Data.Description) + } + output.KeyValue("Built-in", fmt.Sprintf("%t", result.Data.BuiltIn)) + output.KeyValue("Assigned users", fmt.Sprintf("%d", result.Data.AssignedUserCount)) + output.KeyValue("Permissions", fmt.Sprintf("%d", len(result.Data.Permissions))) + if len(result.Data.Permissions) > 0 { + rows := make([][]string, len(result.Data.Permissions)) + for i, p := range result.Data.Permissions { + rows[i] = []string{p} + } + output.Table([]string{"PERMISSION"}, rows) + } + return nil + }, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a custom role", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if roleCreateName == "" { + return fmt.Errorf("--name is required") + } + if len(roleCreatePermissions) == 0 { + return fmt.Errorf("at least one --permission is required") + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + req := roletypes.CreateRole{ + Name: roleCreateName, + Permissions: roleCreatePermissions, + } + if cmd.Flags().Changed("description") { + req.Description = &roleCreateDescription + } + + resp, err := c.Post(cmd.Context(), types.Endpoints.Roles(), req) + if err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + output.Success("Role %s created", result.Data.Name) + output.KeyValue("ID", result.Data.ID) + output.KeyValue("Permissions", fmt.Sprintf("%d", len(result.Data.Permissions))) + return nil + }, +} + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a custom role (built-in roles are immutable)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + // The PUT contract requires every field — fetch the current state + // and overlay only the flags the caller set, so partial updates work + // from the CLI without forcing the user to retype everything. + current, err := fetchRoleInternal(cmd, args[0]) + if err != nil { + return err + } + + req := roletypes.UpdateRole{ + Name: current.Name, + Description: current.Description, + Permissions: current.Permissions, + } + if cmd.Flags().Changed("name") { + req.Name = roleUpdateName + } + if cmd.Flags().Changed("description") { + req.Description = &roleUpdateDescription + } + if cmd.Flags().Changed("permission") { + req.Permissions = roleUpdatePermissions + } + + resp, err := c.Put(cmd.Context(), types.Endpoints.Role(args[0]), req) + if err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + + output.Success("Role updated") + return nil + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm", "remove"}, + Short: "Delete a custom role (built-in roles cannot be deleted)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if !forceFlag { + confirmed, err := cmdutil.Confirm(cmd, fmt.Sprintf("Delete role %s? This removes every assignment of it.", args[0])) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled") + return nil + } + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Delete(cmd.Context(), types.Endpoints.Role(args[0])) + if err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + output.Success("Role deleted") + return nil + }, +} + +// ---------- Permission manifest ---------- + +var permissionsCmd = &cobra.Command{ + Use: "permissions", + Aliases: []string{"perms"}, + Short: "List every permission the server recognizes (the manifest)", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.RolesAvailablePermissions()) + if err != nil { + return fmt.Errorf("failed to load permission manifest: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[roletypes.PermissionsManifest] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to load permission manifest: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + for _, group := range result.Data.Resources { + output.Header("%s (%s)", group.Label, group.Scope) + rows := make([][]string, len(group.Actions)) + for i, a := range group.Actions { + rows[i] = []string{a.Permission, a.Label, a.Description} + } + output.Table([]string{"PERMISSION", "LABEL", "DESCRIPTION"}, rows) + } + return nil + }, +} + +// ---------- User assignments ---------- + +var assignmentsCmd = &cobra.Command{ + Use: "assignments ", + Short: "List a user's role assignments", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.UserRoleAssignments(args[0])) + if err != nil { + return fmt.Errorf("failed to list assignments: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[[]roletypes.RoleAssignment] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list assignments: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + if len(result.Data) == 0 { + output.Info("No role assignments for user %s", args[0]) + return nil + } + rows := make([][]string, len(result.Data)) + for i, a := range result.Data { + scope := "global" + if a.EnvironmentID != nil { + scope = *a.EnvironmentID + } + rows[i] = []string{a.RoleID, scope, a.Source, a.CreatedAt.Format("2006-01-02 15:04")} + } + output.Table([]string{"ROLE", "SCOPE", "SOURCE", "CREATED"}, rows) + return nil + }, +} + +var assignCmd = &cobra.Command{ + Use: "assign ", + Short: "Replace a user's manual role assignments", + Long: "Replace every MANUAL role assignment on the user with the set " + + "passed via --role. OIDC-sourced assignments are left untouched — " + + "manage those via OIDC role mappings.\n\n" + + "Each --role flag accepts `[:]`. Omit the env id for " + + "a global assignment. Pass --role multiple times to assign more than " + + "one role. Pass --role \"\" (empty) once to clear every manual " + + "assignment.", + Example: ` arcane admin roles assign u_123 --role role_editor:env_prod --role role_viewer + arcane admin roles assign u_123 --role role_admin + arcane admin roles assign u_123 --role "" # clear all manual assignments`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + req := roletypes.SetUserAssignments{ + Assignments: parseAssignmentsInternal(assignRoles), + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Put(cmd.Context(), types.Endpoints.UserRoleAssignments(args[0]), req) + if err != nil { + return fmt.Errorf("failed to set assignments: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[[]roletypes.RoleAssignment] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to set assignments: %w", err) + } + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + output.Success("User %s now has %d role assignment(s)", args[0], len(result.Data)) + return nil + }, +} + +// ---------- helpers ---------- + +// parseAssignmentsInternal converts CLI --role tokens into the request payload. +// Format: "" (global) or ":" (env-scoped). A single +// empty token means "clear all assignments". +func parseAssignmentsInternal(tokens []string) []roletypes.UserAssignmentInput { + if len(tokens) == 1 && strings.TrimSpace(tokens[0]) == "" { + return []roletypes.UserAssignmentInput{} + } + out := make([]roletypes.UserAssignmentInput, 0, len(tokens)) + for _, raw := range tokens { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + roleID, envID, hasEnv := strings.Cut(token, ":") + entry := roletypes.UserAssignmentInput{RoleID: roleID} + if hasEnv && envID != "" { + env := envID + entry.EnvironmentID = &env + } + out = append(out, entry) + } + return out +} + +func fetchRoleInternal(cmd *cobra.Command, id string) (*roletypes.Role, error) { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return nil, err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.Role(id)) + if err != nil { + return nil, fmt.Errorf("failed to load current role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("failed to load current role: %w", err) + } + return &result.Data, nil +} + +func init() { + RolesCmd.AddCommand(listCmd) + RolesCmd.AddCommand(getCmd) + RolesCmd.AddCommand(createCmd) + RolesCmd.AddCommand(updateCmd) + RolesCmd.AddCommand(deleteCmd) + RolesCmd.AddCommand(permissionsCmd) + RolesCmd.AddCommand(assignmentsCmd) + RolesCmd.AddCommand(assignCmd) + + listCmd.Flags().IntVarP(&limitFlag, "limit", "n", 20, "Number of roles to show") + listCmd.Flags().IntVar(&startFlag, "start", 0, "Offset for pagination") + listCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + getCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + createCmd.Flags().StringVar(&roleCreateName, "name", "", "Role name (required)") + createCmd.Flags().StringVarP(&roleCreateDescription, "description", "d", "", "Role description") + createCmd.Flags().StringArrayVarP(&roleCreatePermissions, "permission", "p", nil, "Permission to grant (repeatable, e.g. containers:start)") + createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + updateCmd.Flags().StringVar(&roleUpdateName, "name", "", "New role name") + updateCmd.Flags().StringVarP(&roleUpdateDescription, "description", "d", "", "New role description") + updateCmd.Flags().StringArrayVarP(&roleUpdatePermissions, "permission", "p", nil, "Replace the permission set (repeatable)") + updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + deleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Force deletion without confirmation") + + permissionsCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + assignmentsCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + assignCmd.Flags().StringArrayVar(&assignRoles, "role", nil, "Role to grant as `[:]` (repeatable). Pass --role \"\" to clear all manual assignments.") + assignCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + _ = assignCmd.MarkFlagRequired("role") +} diff --git a/cli/pkg/admin/users/cmd.go b/cli/pkg/admin/users/cmd.go index b88709d4bd..5c844de604 100644 --- a/cli/pkg/admin/users/cmd.go +++ b/cli/pkg/admin/users/cmd.go @@ -28,14 +28,12 @@ var ( userCreatePassword string userCreateDisplayName string userCreateEmail string - userCreateRoles []string ) var ( userUpdateUsername string userUpdateDisplayName string userUpdateEmail string - userUpdateRoles []string ) // UsersCmd is the parent command for user operations @@ -43,6 +41,52 @@ var UsersCmd = &cobra.Command{ Use: "users", Aliases: []string{"user", "usr"}, Short: "Manage users", + Long: "Manage users. Role assignments are managed separately via " + + "`arcane admin roles assign` — the legacy `--role` flag has been " + + "removed alongside the binary admin/user model.", +} + +// summarizeRoleAssignments turns a user's role-assignment list into a short +// human label for the list table — e.g. "Admin (global)", "Editor on 2 envs", +// or "Admin (global) + 1 more". Returns "—" when the user holds none. +func summarizeRoleAssignments(assignments []user.RoleAssignmentSummary) string { + if len(assignments) == 0 { + return "—" + } + type bucket struct { + envScoped int + hasGlobal bool + } + buckets := map[string]*bucket{} + order := []string{} + for _, a := range assignments { + b, ok := buckets[a.RoleID] + if !ok { + b = &bucket{} + buckets[a.RoleID] = b + order = append(order, a.RoleID) + } + if a.EnvironmentID == nil { + b.hasGlobal = true + } else { + b.envScoped++ + } + } + parts := make([]string, 0, len(order)) + for _, roleID := range order { + b := buckets[roleID] + switch { + case b.hasGlobal && b.envScoped == 0: + parts = append(parts, fmt.Sprintf("%s (global)", roleID)) + case b.hasGlobal && b.envScoped > 0: + parts = append(parts, fmt.Sprintf("%s (global +%d envs)", roleID, b.envScoped)) + case b.envScoped == 1: + parts = append(parts, fmt.Sprintf("%s on 1 env", roleID)) + default: + parts = append(parts, fmt.Sprintf("%s on %d envs", roleID, b.envScoped)) + } + } + return strings.Join(parts, ", ") } var listCmd = &cobra.Command{ @@ -82,7 +126,7 @@ var listCmd = &cobra.Command{ return nil } - headers := []string{"ID", "USERNAME", "DISPLAY NAME", "EMAIL", "ROLES"} + headers := []string{"ID", "USERNAME", "DISPLAY NAME", "EMAIL", "ROLE ASSIGNMENTS"} rows := make([][]string, len(result.Data)) for i, usr := range result.Data { displayName := "" @@ -93,13 +137,12 @@ var listCmd = &cobra.Command{ if usr.Email != nil { email = *usr.Email } - roles := strings.Join(usr.Roles, ", ") rows[i] = []string{ usr.ID, usr.Username, displayName, email, - roles, + summarizeRoleAssignments(usr.RoleAssignments), } } @@ -110,8 +153,11 @@ var listCmd = &cobra.Command{ } var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new user", + Use: "create", + Short: "Create a new user", + Long: "Create a new user. The user is created without any role " + + "assignments — grant them via `arcane admin roles assign ` " + + "after creation.", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { c, err := client.NewFromConfig() @@ -139,9 +185,6 @@ var createCmd = &cobra.Command{ if cmd.Flags().Changed("email") { req.Email = &userCreateEmail } - if cmd.Flags().Changed("role") { - req.Roles = userCreateRoles - } resp, err := c.Post(cmd.Context(), types.Endpoints.Users(), req) if err != nil { @@ -169,7 +212,7 @@ var createCmd = &cobra.Command{ output.Success("User %s created successfully", result.Data.Username) output.KeyValue("ID", result.Data.ID) output.KeyValue("Username", result.Data.Username) - output.KeyValue("Roles", strings.Join(result.Data.Roles, ", ")) + output.Info("No roles assigned. Use `arcane admin roles assign %s --role ` to grant access.", result.Data.ID) return nil }, } @@ -214,15 +257,26 @@ var getCmd = &cobra.Command{ if result.Data.Email != nil { output.KeyValue("Email", *result.Data.Email) } - output.KeyValue("Roles", strings.Join(result.Data.Roles, ", ")) output.KeyValue("Created", result.Data.CreatedAt) + output.KeyValue("Role Assignments", summarizeRoleAssignments(result.Data.RoleAssignments)) + if len(result.Data.RoleAssignments) > 0 { + rows := make([][]string, len(result.Data.RoleAssignments)) + for i, a := range result.Data.RoleAssignments { + env := "global" + if a.EnvironmentID != nil { + env = *a.EnvironmentID + } + rows[i] = []string{a.RoleID, env, a.Source} + } + output.Table([]string{"ROLE", "SCOPE", "SOURCE"}, rows) + } return nil }, } var updateCmd = &cobra.Command{ Use: "update ", - Short: "Update user", + Short: "Update user profile (role assignments managed separately)", Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -241,9 +295,6 @@ var updateCmd = &cobra.Command{ if cmd.Flags().Changed("email") { req.Email = &userUpdateEmail } - if cmd.Flags().Changed("role") { - req.Roles = userUpdateRoles - } resp, err := c.Put(cmd.Context(), types.Endpoints.User(args[0]), req) if err != nil { @@ -321,7 +372,6 @@ func init() { createCmd.Flags().StringVar(&userCreatePassword, "password", "", "Password (if omitted, will prompt securely)") createCmd.Flags().StringVar(&userCreateDisplayName, "display-name", "", "Display name") createCmd.Flags().StringVar(&userCreateEmail, "email", "", "Email address") - createCmd.Flags().StringArrayVar(&userCreateRoles, "role", nil, "User role") createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") _ = createCmd.MarkFlagRequired("username") @@ -330,7 +380,6 @@ func init() { updateCmd.Flags().StringVar(&userUpdateUsername, "username", "", "New username") updateCmd.Flags().StringVar(&userUpdateDisplayName, "display-name", "", "Display name") updateCmd.Flags().StringVar(&userUpdateEmail, "email", "", "Email address") - updateCmd.Flags().StringArrayVar(&userUpdateRoles, "role", nil, "User role") updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") deleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Force deletion without confirmation") diff --git a/docs/rbac.md b/docs/rbac.md new file mode 100644 index 0000000000..17711c8626 --- /dev/null +++ b/docs/rbac.md @@ -0,0 +1,180 @@ +# Role-Based Access Control + +Arcane uses a role-based access control (RBAC) system to govern what users and API keys can do. Every action — starting a container, deploying a project, editing settings — is gated by a specific permission. Permissions are bundled into roles, and roles are assigned to users (optionally scoped to a specific environment). + +If you're upgrading from an earlier version of Arcane, see the [Upgrade & Migration](#upgrade--migration) section below. + +## The mental model + +There are four moving parts: + +- **Permissions** are fine-grained strings of the form `:` — for example `containers:start`, `projects:deploy`, `users:create`. You don't pick these one-by-one; you bundle them into roles. +- **Roles** are named permission sets. Arcane ships with four built-in roles (Admin, Editor, Deployer, Viewer) and lets admins create custom ones. +- **Role assignments** bind a user to a role, optionally scoped to a single environment. A user can hold many assignments — for example *Admin globally*, or *Editor on production + Viewer on staging*. +- **OIDC mappings** translate SSO group claims into role assignments automatically on every login. + +Permissions split into two scopes: + +- **Org-level permissions** apply across the whole instance. Settings, users, registries, templates, environments management — these are all org-level. To get an org-level permission you need a **globally-scoped** role assignment. +- **Env-scoped permissions** apply to one environment at a time. Containers, projects, images, volumes, networks, swarm, GitOps — these are all env-scoped. You can hold a different role on each environment. + +A user with `Editor on production` can manage Docker resources on that environment, but cannot change settings (org-level), and has no access at all on other environments. + +## Built-in roles + +The six built-in roles cover the common access patterns. They are immutable — you can't edit or delete them, but you can clone one as a starting point for a custom role. + +| Role | Intended use | What it can do | +| -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Admin** | Full instance operators | Everything. Manages users, roles, settings, registries, and every Docker resource on every environment. | +| **Editor** | Day-to-day Docker management | Read+write on containers, projects, images, volumes, networks, swarm resources, GitOps syncs, webhooks, jobs, notifications, vulnerabilities. Read-only on settings/users. | +| **No-Shell Editor** | Editor without `exec` access | Same as Editor, but cannot open an interactive shell inside a running container. | +| **Deployer** | CI/CD service accounts and on-call responders | Deploy projects, restart/start/stop/redeploy containers, sync GitOps, pull images. Cannot create or delete resources, cannot manage settings or users. | +| **Monitor** | Observability and on-call read access | Read Docker resources, view container/project/service logs, dashboards, events. No mutations, no exec, no settings access. | +| **Viewer** | Auditors and read-only stakeholders | Read-only access to every Docker resource and to most org-level pages. Cannot view logs, start, stop, change, or delete anything. | + +You must have at least one user holding **Admin** globally at all times. Arcane will refuse role-assignment edits, OIDC mapping changes, and user deletions that would leave the system with zero global admins. + +## Custom roles + +> **Admin-only:** creating, editing, and deleting roles — and assigning roles to users — is reserved for global admins. The same applies to OIDC group mappings. These actions are intentionally not delegatable as fine-grained permissions; the matching API routes return 403 to non-admins. + +Admins can create custom roles to fit narrower job functions — for example, "Database Operator" that can manage only volumes and backups, or "Security Reviewer" that can read vulnerability scans but trigger nothing. + +To create a custom role: + +1. Open **Settings → Roles**. +2. Click **Create role**. +3. Name the role, optionally add a description. +4. In the permission picker on the right, expand each resource group and check the actions this role should grant. A "select all" checkbox at the top of each group is handy for full-read or full-write roles. +5. Save. + +Custom roles can be edited and deleted at any time. Built-in roles show a badge and the editor is read-only — use the **Clone as custom role** button to start a new role pre-populated with a built-in's permissions. + +When you delete a custom role, every assignment of it is also removed. The global-admin guard still applies — if removing the role would orphan the last global Admin, the deletion is refused. + +## Assigning roles to users + +From **Settings → Users**, open a user. The **Role assignments** section shows every role they hold, with a scope label (Global or a specific environment name). You can: + +- Add a row to grant a new role on an environment (or globally). +- Remove a row to revoke that grant. +- Use the **Global** scope to grant org-level permissions. + +A user with no assignments at all can authenticate but cannot read or do anything — they'll land on a "no access" screen until an admin grants them a role. + +### OIDC users + +If a user authenticated via OIDC matches an OIDC role mapping, their assignments come from the mapping table and are managed there. The user editor will be read-only for those assignments and link to **Settings → OIDC Mappings**. Manual assignments on OIDC users (for groups that have no mapping) still work and are preserved across logins. + +## OIDC group mappings + +For SSO-only deployments, role assignment can be driven entirely from your IdP. Map an OIDC group claim value to a role and an optional environment scope; on every login Arcane reads the user's group claim and re-syncs their `source=oidc` assignments to match. + +### Setup + +1. Confirm your IdP issues a groups claim. By default Arcane reads it from the `groups` claim — you can change this in **Settings → Settings** under **OIDC Groups Claim**. Common alternatives are `realm_access.roles` (Keycloak), `memberOf` (Azure AD), or a custom claim path. +2. Open **Settings → OIDC Mappings**. +3. Click **Add mapping** and fill in: + - **Claim value**: the exact string that appears in the user's groups claim (e.g. `docker-admins`) + - **Role**: the role to grant when this claim is present + - **Environment scope**: `Global` for org-level role, or a specific environment +4. Save. + +A user who is a member of multiple mapped groups receives the **union** of the matching assignments. A user demoted in the IdP loses their OIDC-sourced assignments on next login; their manual assignments (if any) remain. + +The legacy `OidcAdminClaim` / `OidcAdminValue` settings continue to work as an implicit "matching claim grants Admin globally" mapping. New deployments should configure mappings explicitly and leave those settings empty. + +### Declaring mappings via env (GitOps / IaC) + +For deployments that prefer to keep everything in source control, mappings can be declared via the `OIDC_ROLE_MAPPINGS` env var. The value is a JSON array of `{claimValue, roleId, environmentId?}` entries; reconciliation runs at every boot and is fully declarative — adding an entry creates it, removing one deletes it, editing one updates it. Manual mappings created in the UI are never touched. + +``` +OIDC_ROLE_MAPPINGS='[ + {"claimValue": "docker-admins", "roleId": "role_admin"}, + {"claimValue": "devops", "roleId": "role_editor", "environmentId": "env-prod"} +]' +``` + +Env-managed rows are flagged with a `ENV` badge in the UI and are read-only — the Edit/Delete actions are disabled, and the API returns 409 if you try to mutate them. To change one, edit the env var and restart. Set `OIDC_ROLE_MAPPINGS='[]'` to wipe every env-managed row. The matching `OIDC_ROLE_MAPPINGS_FILE` env var works for Docker secret files. + +## API keys + +Every API key now carries its own permission set, independent of the owning user. This means you can issue narrow-scoped keys for CI/CD ("can deploy projects on prod, nothing else") without granting the owning user the same scope. + +### Creating a scoped API key + +1. Open **Settings → API Keys** and click **Create API key**. +2. Fill in name, description, optional expiration. +3. In the **Permissions** section, check the permissions this key should hold. Use environment scope just like role assignments. +4. Save and copy the key value — it is shown only once. + +You cannot grant a key permissions you don't have yourself. Server-side validation rejects creating a key whose permission set exceeds the creator's effective permission set. + +### What happens to existing keys on upgrade + +Existing API keys created before this release inherit a snapshot of their owner's current effective permissions at upgrade time, scoped per the key's environment binding. If an owner's permissions change later, their existing keys are *not* automatically re-scoped — admins should review and re-issue keys after large permission changes. + +## The permission catalog + +Permissions follow `:`. You don't usually pick them individually — the role editor groups them by resource — but here's the catalog for reference. + +### Org-level (require global-scope role) + +- **users**: `list`, `read`, `create`, `update`, `delete` +- **roles**: `list`, `read` *(creating, editing, deleting, and assigning roles are admin-only — not delegatable)* +- **apikeys**: `list`, `read`, `create`, `update`, `delete` +- **settings**: `read`, `write` +- **environments**: `list`, `read`, `create`, `update`, `delete`, `pair`, `sync` +- **registries** (container registries): `list`, `read`, `create`, `update`, `delete`, `test` +- **templates**: `list`, `read`, `create`, `update`, `delete` +- **git-repositories**: `list`, `read`, `create`, `update`, `delete`, `test`, `sync` +- **events**: `read` +- **customize**: `manage` + +### Env-scoped (per environment) + +- **containers**: `list`, `read`, `logs`, `create`, `start`, `stop`, `restart`, `redeploy`, `delete`, `exec`, `autoupdate` +- **projects**: `list`, `read`, `logs`, `create`, `update`, `deploy`, `down`, `restart`, `delete`, `archive` +- **images**: `list`, `read`, `pull`, `push`, `build`, `prune`, `delete`, `upload` +- **volumes**: `list`, `read`, `create`, `delete`, `prune`, `browse`, `upload`, `backup` +- **networks**: `list`, `read`, `create`, `delete`, `prune` +- **swarm**: `read`, `init`, `join`, `leave`, `spec`, `nodes`, `services`, `services:logs`, `stacks`, `configs`, `secrets`, `unlock` +- **gitops**: `list`, `read`, `create`, `update`, `delete`, `sync` +- **webhooks**: `list`, `create`, `update`, `delete` +- **jobs**: `manage` +- **notifications**: `manage` +- **dashboard**: `read` +- **system**: `read`, `prune`, `upgrade` +- **image-updates**: `read`, `check` +- **vulnerabilities**: `read`, `scan`, `manage` +- **build-workspaces**: `manage` + +## Upgrade & migration + +When you upgrade from a pre-RBAC release of Arcane, the migration runs automatically the first time the new server starts: + +- Every user that held the legacy `admin` role gets a **global Admin** assignment. +- Every other user gets a single **global Viewer** assignment (read-only across everything, including the Settings area). +- Existing API keys inherit a snapshot of their owner's effective permissions. + +If for some reason the migration would leave no global admins, Arcane refuses to start and logs an error — restore from backup and investigate before retrying. + +After the upgrade you should: + +1. **Review your admins.** `Settings → Users` shows every user and their assignments at a glance. +2. **Right-size non-admins.** Defaulting everyone to global Viewer is safe but very restrictive (read-only, no logs). Promote individual users to Editor / No-Shell Editor / Deployer / Monitor on the environments they actually use. +3. **Map OIDC groups, if you use SSO.** Set the `OIDC Groups Claim` and add mappings under `Settings → OIDC Mappings`. Existing legacy `OidcAdminClaim` / `OidcAdminValue` settings keep working, but mappings give you finer control. +4. **Review API keys.** Each key now carries its own permissions; the upgrade gives them a snapshot of the owner's permissions, which is the most permissive safe default. Tighten down CI/CD keys to least privilege. + +## Troubleshooting + +**"permission denied: ..." errors after upgrade.** Find the permission string in the error, look it up in the catalog above, and check whether the caller's role grants it on the right environment. Admins can audit a user's effective permissions from `Settings → Users`. + +**A user has no access at all.** They probably have no role assignments. Open them in `Settings → Users` and add at least one assignment (typically `Viewer` on every environment they should see). + +**Last-admin guard tripped (`at least one user must retain a global Admin role assignment`).** You tried to remove, demote, or OIDC-resync away the last globally-scoped Admin. Add another global Admin first, then retry. + +**OIDC user lost their access on login.** Their group claim no longer matches any mapping. Check the IdP-side group membership and the mapping table; OIDC-sourced assignments are re-evaluated on every login. + +**API key fails with permission denied.** Keys carry their own permissions, not the owner's current permissions. If you changed the owner's role, the key still has whatever it was provisioned with. Re-issue the key with the desired scope. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cbb073dc09..7368f8b6e1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2050,13 +2050,11 @@ "oidc_issuer_url_description": "The issuer URL will be used to auto-discover OIDC endpoints.", "oidc_scopes_label": "Scopes", "oidc_scopes_placeholder": "openid email profile", - "oidc_admin_claim_label": "Admin Claim", - "oidc_admin_value_label": "Required Value(s)", - "oidc_admin_role_mapping_title": "Admin Role Mapping", - "oidc_admin_role_mapping_description": "Map an OIDC claim to grant the admin role. Examples: roles=admin, groups=admin, or admin=true.", - "oidc_admin_claim_placeholder": "e.g., roles, groups, realm_access.roles, admin", - "oidc_admin_value_placeholder": "e.g., admin (comma-separated)", - "oidc_admin_value_help": "Leave empty for boolean claims (admin=true).", + "oidc_groups_claim_label": "Groups Claim", + "oidc_groups_claim_placeholder": "groups", + "oidc_groups_claim_help": "JSON path to the claim that contains the user's groups. Default: groups. Examples: groups, realm_access.roles, memberOf.", + "oidc_role_mappings_title": "Role Mappings", + "oidc_role_mappings_description": "Map claim values to roles assigned on login. Manual role assignments are preserved across logins.", "oidc_skip_tls_verify_label": "Skip TLS Verification", "oidc_skip_tls_verify_description": "Disable TLS certificate verification for OIDC endpoints. Proceed with CAUTION.", "oidc_auto_redirect_label": "Auto Redirect to Provider", @@ -2501,6 +2499,9 @@ "api_key_delete_success": "API key \"{name}\" deleted successfully", "api_key_static_title": "Static Admin API Key", "api_key_static_description": "This API key is managed by ADMIN_STATIC_API_KEY and cannot be edited or deleted from the UI.", + "api_key_bootstrap_title": "Environment Bootstrap API Key", + "api_key_bootstrap_description": "This API key is auto-generated for the agent pairing flow and cannot be edited or deleted from the UI.", + "api_key_bootstrap_locked_description": "This is an auto-generated key used by the agent pairing flow for an environment. It's locked from manual edits — modifying its permissions or deleting it would break the paired edge agent.", "api_key_bulk_delete_success": "Successfully deleted {count} API key(s)", "api_key_bulk_delete_failed": "Failed to delete {count} API key(s)", "api_key_bulk_delete_static_skipped": "Skipped {count} static API key(s). Only deletable keys will be removed.", @@ -2774,5 +2775,84 @@ "webhook_disable_failed": "Failed to disable webhook \"{name}\"", "webhook_status_enabled": "Enabled", "webhook_status_disabled": "Disabled", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "resource_role": "role", + "resource_role_cap": "Role", + "resource_oidc_mapping": "mapping", + "resource_oidc_mapping_cap": "Mapping", + "_comment_roles": "=== ROLES & RBAC ===", + "roles_title": "Roles", + "roles_subtitle": "Define what users and API keys can do", + "roles_create_title": "Create role", + "roles_edit_title": "Edit role", + "roles_delete_message": "Delete role \"{name}\"? This removes every assignment of it.", + "roles_save_changes": "Save changes", + "roles_clone_button": "Clone as custom role", + "roles_clone_success": "Cloned to \"{name}\"", + "roles_clone_failed": "Failed to clone role", + "roles_built_in": "Built-in", + "roles_built_in_note": "Built-in roles cannot be edited or deleted. Use Clone to start from this set of permissions.", + "roles_custom": "Custom", + "roles_name_label": "Name", + "roles_name_placeholder": "e.g. Deploy Bot", + "roles_name_required": "Name is required", + "roles_description_label": "Description", + "roles_description_placeholder": "What is this role for?", + "roles_permissions_label": "Permissions", + "roles_permissions_required": "Pick at least one permission", + "roles_permissions_count": "{count} of {total} permissions", + "roles_col_type": "Type", + "roles_col_assigned_users": "Assigned users", + "roles_col_permissions": "Permissions", + "roles_assigned_users_count": "{count} users", + "_comment_permissions": "=== PERMISSION PICKER ===", + "permissions_group_label": "{resource} ({selected}/{total})", + "permissions_select_all": "Select all", + "permissions_search_placeholder": "Filter permissions…", + "permissions_no_matches": "No permissions match this search.", + "permissions_scope_global": "Global", + "permissions_scope_env": "Per-environment", + "_comment_oidc_mappings": "=== OIDC ROLE MAPPINGS ===", + "oidc_mappings_title": "OIDC Mappings", + "oidc_mappings_subtitle": "Map OIDC group claims to roles assigned on login", + "oidc_mappings_create_title": "Add OIDC mapping", + "oidc_mappings_edit_title": "Edit OIDC mapping", + "oidc_mappings_delete_message": "Delete mapping for claim \"{claim}\"? Users logged in through this claim will lose their assigned role on next login.", + "oidc_mappings_col_claim": "Claim value", + "oidc_mappings_col_scope": "Environment", + "oidc_mappings_claim_label": "Claim value", + "oidc_mappings_claim_placeholder": "e.g. docker-admins", + "oidc_mappings_claim_required": "Claim value is required", + "oidc_mappings_role_label": "Role", + "oidc_mappings_role_required": "Role is required", + "oidc_mappings_scope_label": "Environment scope", + "oidc_mappings_scope_global_option": "Global (org-wide)", + "oidc_mappings_empty_title": "No OIDC mappings yet", + "oidc_mappings_empty_body": "When OIDC users log in, Arcane checks their group claim against these mappings to assign roles. Configure the claim under Authentication settings.", + "_comment_user_role_assignments": "=== USER ROLE ASSIGNMENTS ===", + "users_role_assignments_label": "Role assignments", + "users_role_assignments_description": "Pick the role this user holds on each environment. Add a Global assignment for org-wide permissions.", + "users_role_assignments_required": "Pick at least one role assignment", + "users_role_assignments_add": "Add assignment", + "users_role_assignments_remove": "Remove", + "users_role_assignments_environment": "Environment", + "users_role_assignments_role": "Role", + "users_role_assignments_scope_global": "Global (org-wide)", + "users_role_assignments_oidc_managed": "Role assignments for this user are managed by OIDC group mappings. Manual changes will be overwritten on next login.", + "users_role_assignments_oidc_link": "Manage OIDC mappings", + "users_role_summary_none": "No access", + "users_role_summary_env_count": "Assigned on {count} environments", + "_comment_api_key_permissions": "=== API KEY PERMISSIONS ===", + "api_key_permissions_description": "Choose the permissions this key may use. You cannot grant a permission you do not hold yourself.", + "api_key_permissions_required": "Pick at least one permission", + "api_key_scope_all": "All permissions", + "api_key_scope_role": "Matches {role}", + "api_key_scope_count": "{count} {count, plural, one {permission} other {permissions}}", + "_comment_rbac_migration": "=== RBAC MIGRATION BANNER ===", + "rbac_migration_banner_title": "Welcome to RBAC", + "rbac_migration_banner_body": "Your access was migrated to Viewer on all environments. Admins can promote users from Settings → Users.", + "rbac_migration_banner_dismiss": "Dismiss", + "_comment_no_access": "=== NO ACCESS PAGE ===", + "no_access_page_title": "You don't have access to anything yet", + "no_access_page_body": "Your Arcane administrator hasn't assigned you any role permissions. Ask them to add a role to your account from Settings → Users." } diff --git a/frontend/src/lib/components/action-buttons.svelte b/frontend/src/lib/components/action-buttons.svelte index c90836b8b0..027ab873b9 100644 --- a/frontend/src/lib/components/action-buttons.svelte +++ b/frontend/src/lib/components/action-buttons.svelte @@ -19,6 +19,8 @@ import { sanitizeLogText } from '$lib/utils/log-text'; import { EllipsisIcon, DownloadIcon, HammerIcon } from '$lib/icons'; import { createMutation } from '@tanstack/svelte-query'; + import { hasPermission } from '$lib/utils/permissions.util'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; type TargetType = 'container' | 'project'; type LoadingStates = { @@ -190,6 +192,29 @@ const isRunning = $derived(itemState === 'running' || (type === 'project' && itemState === 'partially running')); const projectHasBuildDirective = $derived(type === 'project' && hasBuildDirective); + + // Per-action RBAC gating. Each button hides if the caller lacks the + // corresponding permission on the currently-selected environment. Project + // pull / build / redeploy all share the `projects:deploy` permission since + // they're stages of the deploy flow. + const currentEnvId = $derived(environmentStore.selected?.id); + const canStart = $derived( + type === 'container' ? hasPermission('containers:start', currentEnvId) : hasPermission('projects:deploy', currentEnvId) + ); + const canStop = $derived( + type === 'container' ? hasPermission('containers:stop', currentEnvId) : hasPermission('projects:down', currentEnvId) + ); + const canRestart = $derived( + type === 'container' ? hasPermission('containers:restart', currentEnvId) : hasPermission('projects:restart', currentEnvId) + ); + const canRedeploy = $derived( + type === 'container' ? hasPermission('containers:redeploy', currentEnvId) : hasPermission('projects:deploy', currentEnvId) + ); + const canRemove = $derived( + type === 'container' ? hasPermission('containers:delete', currentEnvId) : hasPermission('projects:delete', currentEnvId) + ); + const canPull = $derived(type === 'project' && hasPermission('projects:deploy', currentEnvId)); + const canBuild = $derived(type === 'project' && hasPermission('projects:deploy', currentEnvId)); const deployButtonLabel = $derived(projectHasBuildDirective ? m.compose_build_and_deploy() : m.common_up()); const depotAvailable = $derived.by(() => { const projectId = ($settingsStore?.depotProjectId ?? '').trim(); @@ -819,24 +844,28 @@ {#snippet RedeployActionButton(size: 'default' | 'icon' = 'default', showLabel = true)} - {#if disableRedeploy} - - - - {:else} - confirmAction('redeploy')} loading={uiLoading.redeploy} /> + {#if canRedeploy} + {#if disableRedeploy} + + + + {:else} + confirmAction('redeploy')} loading={uiLoading.redeploy} /> + {/if} {/if} {/snippet} {#snippet RedeployMenuItem()} - {#if disableRedeploy} - - {m.common_redeploy()} - - {:else} - confirmAction('redeploy')} disabled={uiLoading.redeploy}> - {m.common_redeploy()} - + {#if canRedeploy} + {#if disableRedeploy} + + {m.common_redeploy()} + + {:else} + confirmAction('redeploy')} disabled={uiLoading.redeploy}> + {m.common_redeploy()} + + {/if} {/if} {/snippet} @@ -845,7 +874,7 @@ {#if isLgUp}
- {#if !isRunning} + {#if !isRunning && canStart} {#if type === 'container'} handleStop()} - loading={uiLoading.stop} - /> - handleRestart()} - loading={uiLoading.restart} - /> + {#if canStop} + handleStop()} + loading={uiLoading.stop} + /> + {/if} + {#if canRestart} + handleRestart()} + loading={uiLoading.restart} + /> + {/if} {/if} {#if type === 'container'} {@render RedeployActionButton(adaptiveIconOnly ? 'icon' : 'default', !adaptiveIconOnly)} - confirmAction('remove')} - loading={uiLoading.remove} - /> + {#if canRemove} + confirmAction('remove')} + loading={uiLoading.remove} + /> + {/if} {:else} {@render RedeployActionButton(adaptiveIconOnly ? 'icon' : 'default', !adaptiveIconOnly)} {#if type === 'project'} - {#if projectHasBuildDirective} + {#if projectHasBuildDirective && canBuild} {/if} - - handleProjectPull()} + {#if canPull} + - + icon={DownloadIcon} + layers={layerProgress} + > + handleProjectPull()} + loading={projectPulling} + /> + + {/if} {/if} {#if onRefresh} @@ -966,14 +1003,16 @@ /> {/if} - confirmAction('remove')} - loading={uiLoading.remove} - /> + {#if canRemove} + confirmAction('remove')} + loading={uiLoading.remove} + /> + {/if} {/if}
{:else} @@ -989,7 +1028,7 @@ class="bg-popover/20 z-50 min-w-[180px] rounded-xl border p-1 shadow-lg backdrop-blur-md" > - {#if !isRunning} + {#if !isRunning && canStart} {#if type === 'container'} {m.common_start()} @@ -1019,32 +1058,40 @@ {/if} {/if} - {:else} - - {type === 'project' ? m.common_down() : m.common_stop()} - - - {m.common_restart()} - + {:else if isRunning} + {#if canStop} + + {type === 'project' ? m.common_down() : m.common_stop()} + + {/if} + {#if canRestart} + + {m.common_restart()} + + {/if} {/if} {#if type === 'container'} {@render RedeployMenuItem()} - confirmAction('remove')} disabled={uiLoading.remove}> - {m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {m.common_remove()} + + {/if} {:else} {@render RedeployMenuItem()} {#if type === 'project'} - {#if projectHasBuildDirective} + {#if projectHasBuildDirective && canBuild} {m.build()} {/if} - - {m.images_pull()} - + {#if canPull} + + {m.images_pull()} + + {/if} {/if} {#if onRefresh} @@ -1053,9 +1100,11 @@ {/if} - confirmAction('remove')} disabled={uiLoading.remove}> - {type === 'project' ? m.compose_destroy() : m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {type === 'project' ? m.compose_destroy() : m.common_remove()} + + {/if} {/if} @@ -1118,7 +1167,7 @@ {:else}
@@ -1216,7 +1275,7 @@ class="bg-popover/20 z-50 min-w-[180px] rounded-xl border p-1 shadow-lg backdrop-blur-md" > - {#if !isRunning} + {#if !isRunning && canStart} {#if type === 'container'} {m.common_start()} @@ -1246,32 +1305,40 @@ {/if} {/if} - {:else} - - {type === 'project' ? m.common_down() : m.common_stop()} - - - {m.common_restart()} - + {:else if isRunning} + {#if canStop} + + {type === 'project' ? m.common_down() : m.common_stop()} + + {/if} + {#if canRestart} + + {m.common_restart()} + + {/if} {/if} {#if type === 'container'} {@render RedeployMenuItem()} - confirmAction('remove')} disabled={uiLoading.remove}> - {m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {m.common_remove()} + + {/if} {:else} {@render RedeployMenuItem()} {#if type === 'project'} - {#if projectHasBuildDirective} + {#if projectHasBuildDirective && canBuild} {m.build()} {/if} - - {m.images_pull()} - + {#if canPull} + + {m.images_pull()} + + {/if} {/if} {#if onRefresh} @@ -1280,9 +1347,11 @@ {/if} - confirmAction('remove')} disabled={uiLoading.remove}> - {type === 'project' ? m.compose_destroy() : m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {type === 'project' ? m.compose_destroy() : m.common_remove()} + + {/if} {/if} diff --git a/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte b/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte index cf672ff3e9..cb8674819f 100644 --- a/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte +++ b/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte @@ -17,13 +17,13 @@ import { tick } from 'svelte'; import { EnvironmentsIcon, RemoteEnvironmentIcon, AddIcon, SearchIcon, CloseIcon, SettingsIcon } from '$lib/icons'; import { useQueryClient } from '@tanstack/svelte-query'; + import IfPermitted from '$lib/components/if-permitted.svelte'; type Props = { open: boolean; - isAdmin?: boolean; }; - let { open = $bindable(false), isAdmin = false }: Props = $props(); + let { open = $bindable(false) }: Props = $props(); const queryClient = useQueryClient(); let searchQuery = $state(''); @@ -356,7 +356,7 @@ {#snippet footer()}
- {#if isAdmin} + - {:else} -
- {/if} + {#snippet fallback()} +
+ {/snippet} +
{/snippet} diff --git a/frontend/src/lib/components/dialogs/rbac-migration-banner.svelte b/frontend/src/lib/components/dialogs/rbac-migration-banner.svelte new file mode 100644 index 0000000000..5e8ad44359 --- /dev/null +++ b/frontend/src/lib/components/dialogs/rbac-migration-banner.svelte @@ -0,0 +1,71 @@ + + +{#if visible} + +
+ {m.rbac_migration_banner_title()} + {m.rbac_migration_banner_body()} +
+ +
+{/if} diff --git a/frontend/src/lib/components/file-browser/FileList.svelte b/frontend/src/lib/components/file-browser/FileList.svelte index dc0983e426..6e780856f1 100644 --- a/frontend/src/lib/components/file-browser/FileList.svelte +++ b/frontend/src/lib/components/file-browser/FileList.svelte @@ -40,7 +40,7 @@ persistKey?: string; onNavigate: (path: string) => void; onRefresh: () => void; - onDelete: (file: FileEntry) => Promise; + onDelete?: (file: FileEntry) => Promise; onDownload: (file: FileEntry) => Promise; onPreview: (file: FileEntry) => void; onRestoreFromBackup?: (file: FileEntry) => void; @@ -102,6 +102,7 @@ } async function handleDelete(file: FileEntry) { + if (!onDelete) return; openConfirmDialog({ title: m.common_remove_title({ resource: file.name }), message: m.volumes_browser_delete_confirm({ name: file.name }), @@ -225,10 +226,12 @@ {/if} {/if} - handleDelete(item)}> - - {m.common_delete()} - + {#if onDelete} + handleDelete(item)}> + + {m.common_delete()} + + {/if} diff --git a/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte b/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte index 5011782373..216cf9d963 100644 --- a/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte +++ b/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte @@ -32,9 +32,16 @@ import { toast } from 'svelte-sonner'; import bytes from '$lib/utils/bytes'; import { format } from 'date-fns'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { provider, rootLabel, persistKey }: { provider: FileProvider; rootLabel?: string; persistKey?: string } = $props(); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canDeleteVolume = $derived(hasPermission('volumes:delete', currentEnvId)); + const canBackupVolume = $derived(hasPermission('volumes:backup', currentEnvId)); + let currentPath = $state('/'); let files = $state([]); let loading = $state(true); @@ -167,22 +174,24 @@
- (showCreateFolder = true)} - icon={MoveToFolderIcon} - customLabel={m.volumes_browser_new_folder()} - /> - (showUpload = true)} - icon={UploadIcon} - customLabel={m.volumes_browser_upload_files()} - /> + + (showCreateFolder = true)} + icon={MoveToFolderIcon} + customLabel={m.volumes_browser_new_folder()} + /> + (showUpload = true)} + icon={UploadIcon} + customLabel={m.volumes_browser_upload_files()} + /> +
@@ -201,10 +210,10 @@ {persistKey} onNavigate={handleNavigate} onRefresh={() => loadFiles(currentPath)} - onDelete={(file) => provider.delete(file.path)} + onDelete={canDeleteVolume ? (file) => provider.delete(file.path) : undefined} onDownload={(file) => provider.download(file.path)} onPreview={(file) => (previewFile = file)} - onRestoreFromBackup={canRestoreFromBackup ? openRestoreFileDialog : undefined} + onRestoreFromBackup={canRestoreFromBackup && canBackupVolume ? openRestoreFileDialog : undefined} /> {/if}
@@ -315,17 +324,19 @@ selectedBackupId = ''; }} /> - + {#if canBackupVolume} + + {/if} {/snippet} diff --git a/frontend/src/lib/components/forms/role-assignments-editor.svelte b/frontend/src/lib/components/forms/role-assignments-editor.svelte new file mode 100644 index 0000000000..f2855c404f --- /dev/null +++ b/frontend/src/lib/components/forms/role-assignments-editor.svelte @@ -0,0 +1,172 @@ + + +
+ {#if assignments.length === 0} +

+ {m.users_role_assignments_description()} +

+ {/if} + + {#each assignments as assignment, index (`${assignment.roleId}-${assignment.environmentId ?? 'global'}`)} + {@const envValue = envIdToSelectValue(assignment.environmentId)} +
+ updateAssignment(index, { environmentId: selectValueToEnvId(v) })} + > + + {envSelectedLabel(envValue)} + + + {#each envOptions as option (option.id)} + + {option.name} + + {/each} + + + + updateAssignment(index, { roleId: v })} + > + + + {roleSelectedLabel(assignment.roleId)} + + + + {#each roles as role (role.id)} + + + + {#if role.description} + {role.description} + {/if} + + + {/each} + + + + removeAssignment(index)} + {disabled} + class="text-muted-foreground hover:text-destructive justify-self-end" + customLabel={m.users_role_assignments_remove()} + /> +
+ {/each} + + +
diff --git a/frontend/src/lib/components/if-permitted.svelte b/frontend/src/lib/components/if-permitted.svelte new file mode 100644 index 0000000000..5b1e7316c9 --- /dev/null +++ b/frontend/src/lib/components/if-permitted.svelte @@ -0,0 +1,48 @@ + + + +{#if allowed} + {@render children()} +{:else if fallback} + {@render fallback()} +{/if} diff --git a/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte b/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte index 0220573f13..e796da417a 100644 --- a/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte +++ b/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte @@ -1,5 +1,5 @@ @@ -106,94 +115,100 @@ {:else} {/if} - {:else if currentUserIsAdmin} + {:else if hasAnyQuickAction}

{m.quick_actions_title()}

-
- -
-
- + {#if canStartAll} +
+ +
+
+ +
+
-
-
-
-
{m.quick_actions_start_all()}
-
- {m.quick_actions_containers({ count: stoppedContainers })} +
+
{m.quick_actions_start_all()}
+
+ {m.quick_actions_containers({ count: stoppedContainers })} +
-
- -
+ +
+ {/if} -
- -
-
- + {#if canStopAll} +
+ +
+
+ +
+
-
-
-
-
{m.quick_actions_stop_all()}
-
- {m.quick_actions_containers({ count: runningContainers })} +
+
{m.quick_actions_stop_all()}
+
+ {m.quick_actions_containers({ count: runningContainers })} +
-
- -
+ +
+ {/if} -
- -
-
- + {#if canPrune} +
+ +
+
+ +
+
-
-
-
-
{m.quick_actions_prune_system()}
-
{m.quick_actions_prune_description()}
-
- -
+
+
{m.quick_actions_prune_system()}
+
{m.quick_actions_prune_description()}
+
+ +
+ {/if}
{/if} diff --git a/frontend/src/lib/components/role-editor/permission-picker.svelte b/frontend/src/lib/components/role-editor/permission-picker.svelte new file mode 100644 index 0000000000..39f221d095 --- /dev/null +++ b/frontend/src/lib/components/role-editor/permission-picker.svelte @@ -0,0 +1,155 @@ + + +
+ {#if showSearch} + + {/if} + + {#if filteredGroups.length === 0} +

{m.permissions_no_matches()}

+ {:else} + + {#each filteredGroups as group (group.resource.key)} + {@const checkState = groupCheckState(group.resource)} + {@const isAllChecked = checkState === true} + {@const isIndeterminate = checkState === 'indeterminate'} + +
+ toggleGroup(group.resource, checked === true)} + aria-label={m.permissions_select_all()} + /> + +
+ + {m.permissions_group_label({ + resource: group.resource.label, + selected: countSelectedInGroup(group.resource), + total: group.resource.actions.length + })} + + +
+
+
+ +
+ {#each group.actions as action (action.permission)} + {@const checked = selectedSet.has(action.permission)} + + {/each} +
+
+
+ {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/role-editor/role-editor.svelte b/frontend/src/lib/components/role-editor/role-editor.svelte new file mode 100644 index 0000000000..167dd6002b --- /dev/null +++ b/frontend/src/lib/components/role-editor/role-editor.svelte @@ -0,0 +1,132 @@ + + +
+
+ + + + {role ? m.roles_edit_title() : m.roles_create_title()} + + + +
+ +
+ + + + + +
+
+ {m.roles_permissions_count({ count: selectedCount, total: totalPermissions })} +
+ {#if $inputs.permissions?.error} +

{$inputs.permissions.error}

+ {/if} +
+ + {#if isBuiltIn} +

{m.roles_built_in_note()}

+ {/if} + + {#if isBuiltIn && onClone} + + {/if} + + {#if !isBuiltIn} + + {/if} +
+
+
+ +
+ +
+
diff --git a/frontend/src/lib/components/sheets/api-key-form-sheet.svelte b/frontend/src/lib/components/sheets/api-key-form-sheet.svelte index a2ffb9aa04..679930a649 100644 --- a/frontend/src/lib/components/sheets/api-key-form-sheet.svelte +++ b/frontend/src/lib/components/sheets/api-key-form-sheet.svelte @@ -2,7 +2,9 @@ import * as ResponsiveDialog from '$lib/components/ui/responsive-dialog/index.js'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import FormInput from '$lib/components/form/form-input.svelte'; + import PermissionPicker from '$lib/components/role-editor/permission-picker.svelte'; import type { ApiKey } from '$lib/types/api-key.type'; + import type { PermissionsManifest, ApiKeyPermissionGrant } from '$lib/types/role.type'; import { z } from 'zod/v4'; import { createForm, preventDefault } from '$lib/utils/form.utils'; import * as m from '$lib/paraglide/messages.js'; @@ -10,35 +12,53 @@ type ApiKeyFormProps = { open: boolean; apiKeyToEdit: ApiKey | null; + manifest: PermissionsManifest; + availablePermissions?: ApiKeyPermissionGrant[]; onSubmit: (data: { - apiKey: { name: string; description?: string; expiresAt?: string }; + apiKey: { + name: string; + description?: string; + expiresAt?: string; + permissions: ApiKeyPermissionGrant[]; + }; isEditMode: boolean; apiKeyId?: string; }) => void; isLoading: boolean; }; - let { open = $bindable(false), apiKeyToEdit = $bindable(), onSubmit, isLoading }: ApiKeyFormProps = $props(); + let { + open = $bindable(false), + apiKeyToEdit = $bindable(), + manifest, + availablePermissions = [], + onSubmit, + isLoading + }: ApiKeyFormProps = $props(); let isEditMode = $derived(!!apiKeyToEdit); let isStaticApiKey = $derived(apiKeyToEdit?.isStatic ?? false); + let isBootstrapApiKey = $derived(apiKeyToEdit?.isBootstrap ?? false); + let isReadOnlyApiKey = $derived(isStaticApiKey || isBootstrapApiKey); const formSchema = z.object({ name: z.string().min(1, m.common_field_required({ field: m.api_key_name() })), description: z.string().optional(), - expiresAt: z.date().optional() + expiresAt: z.date().optional(), + permissions: z.array(z.string()).min(1, m.api_key_permissions_required()) }); let formData = $derived({ name: apiKeyToEdit?.name || '', description: apiKeyToEdit?.description || '', - expiresAt: apiKeyToEdit?.expiresAt ? new Date(apiKeyToEdit.expiresAt) : undefined + expiresAt: apiKeyToEdit?.expiresAt ? new Date(apiKeyToEdit.expiresAt) : undefined, + permissions: availablePermissions.map((p) => p.permission) }); let { inputs, ...form } = $derived(createForm(formSchema, formData)); function handleSubmit() { - if (isStaticApiKey) return; + if (isReadOnlyApiKey) return; const data = form.validate(); if (!data) return; @@ -46,7 +66,10 @@ const apiKeyData = { name: data.name, description: data.description || undefined, - expiresAt: data.expiresAt ? data.expiresAt.toISOString() : undefined + expiresAt: data.expiresAt ? data.expiresAt.toISOString() : undefined, + // v1: persist all picks as global grants (environmentId undefined). + // env-scoped picking is a follow-up. + permissions: data.permissions.map((p) => ({ permission: p })) }; onSubmit({ apiKey: apiKeyData, isEditMode, apiKeyId: apiKeyToEdit?.id }); @@ -66,25 +89,32 @@ variant="sheet" title={isStaticApiKey ? (apiKeyToEdit?.name ?? m.api_key_static_title()) - : isEditMode - ? m.api_key_edit_title() - : m.api_key_create_title()} + : isBootstrapApiKey + ? (apiKeyToEdit?.name ?? m.api_key_bootstrap_title()) + : isEditMode + ? m.api_key_edit_title() + : m.api_key_create_title()} description={isEditMode ? isStaticApiKey ? m.api_key_static_description() - : m.api_key_edit_description({ name: apiKeyToEdit?.name ?? m.common_unknown() }) + : isBootstrapApiKey + ? m.api_key_bootstrap_description() + : m.api_key_edit_description({ name: apiKeyToEdit?.name ?? m.common_unknown() }) : m.api_key_create_description()} contentClass="sm:max-w-[500px]" > {#snippet children()}
+ {#if isBootstrapApiKey && !isStaticApiKey} +

{m.api_key_bootstrap_locked_description()}

+ {/if} + {#if !isReadOnlyApiKey} +
+ +

{m.api_key_permissions_description()}

+ + {#if $inputs.permissions.error} +

{$inputs.permissions.error}

+ {/if} +
+ {/if} {/snippet} @@ -114,7 +154,7 @@ onclick={() => (open = false)} disabled={isLoading} /> - {#if !isStaticApiKey} + {#if !isReadOnlyApiKey} + import * as ResponsiveDialog from '$lib/components/ui/responsive-dialog/index.js'; + import * as Select from '$lib/components/ui/select'; + import { ArcaneButton } from '$lib/components/arcane-button/index.js'; + import FormInput from '$lib/components/form/form-input.svelte'; + import { Label } from '$lib/components/ui/label'; + import type { OidcRoleMapping, Role } from '$lib/types/role.type'; + import type { Environment } from '$lib/types/environment.type'; + import { z } from 'zod/v4'; + import { createForm, preventDefault } from '$lib/utils/form.utils'; + import { m } from '$lib/paraglide/messages'; + + type Props = { + open: boolean; + mappingToEdit: OidcRoleMapping | null; + roles: Role[]; + environments: Environment[]; + isLoading: boolean; + onSubmit: (data: { claimValue: string; roleId: string; environmentId?: string }) => void; + }; + + let { open = $bindable(false), mappingToEdit, roles, environments, isLoading, onSubmit }: Props = $props(); + + const GLOBAL_OPTION_ID = 'global'; + + const isEditMode = $derived(!!mappingToEdit); + + const envOptions = $derived([ + { id: GLOBAL_OPTION_ID, name: m.oidc_mappings_scope_global_option() }, + ...environments.map((env) => ({ id: env.id, name: env.name })) + ]); + + const formSchema = z.object({ + claimValue: z.string().min(1, m.oidc_mappings_claim_required()), + roleId: z.string().min(1, m.oidc_mappings_role_required()), + environmentId: z.string() + }); + + const formData = $derived({ + claimValue: mappingToEdit?.claimValue ?? '', + roleId: mappingToEdit?.roleId ?? roles[0]?.id ?? '', + environmentId: mappingToEdit?.environmentId ?? GLOBAL_OPTION_ID + }); + + const { inputs, ...form } = $derived(createForm(formSchema, formData)); + + function envSelectedLabel(value: string): string { + return envOptions.find((o) => o.id === value)?.name ?? m.common_select_option(); + } + + function roleSelectedLabel(value: string): string { + return roles.find((r) => r.id === value)?.name ?? m.common_select_option(); + } + + function handleSubmit() { + const data = form.validate(); + if (!data) return; + onSubmit({ + claimValue: data.claimValue, + roleId: data.roleId, + environmentId: data.environmentId === GLOBAL_OPTION_ID ? undefined : data.environmentId + }); + } + + function handleOpenChange(newOpenState: boolean) { + open = newOpenState; + } + + + + {#snippet children()} +
+ + +
+ + + + {roleSelectedLabel($inputs.roleId.value)} + + + {#each roles as role (role.id)} + +
+ {role.name} + {#if role.description} + {role.description} + {/if} +
+
+ {/each} +
+
+ {#if $inputs.roleId.error} +

{$inputs.roleId.error}

+ {/if} +
+ +
+ + + + {envSelectedLabel($inputs.environmentId.value)} + + + {#each envOptions as option (option.id)} + + {option.name} + + {/each} + + +
+ + {/snippet} + + {#snippet footer()} +
+ (open = false)} + disabled={isLoading} + /> + +
+ {/snippet} +
diff --git a/frontend/src/lib/components/sheets/user-form-sheet.svelte b/frontend/src/lib/components/sheets/user-form-sheet.svelte index d4d3604fcf..75047764d7 100644 --- a/frontend/src/lib/components/sheets/user-form-sheet.svelte +++ b/frontend/src/lib/components/sheets/user-form-sheet.svelte @@ -1,18 +1,31 @@ - + @@ -92,17 +101,21 @@ {/if} - - + {#if managementItems.length > 0} + + {/if} + {#if resourceItems.length > 0} + + {/if} {#if swarmItems.length > 0} {/if} - {#if isAdmin} - + {#if settingsItems.length > 0} + {/if} - + {#if effectiveUser} {#if isCollapsed}
diff --git a/frontend/src/lib/config/navigation-config.ts b/frontend/src/lib/config/navigation-config.ts index 9c56c27916..1a1246ce8a 100644 --- a/frontend/src/lib/config/navigation-config.ts +++ b/frontend/src/lib/config/navigation-config.ts @@ -29,6 +29,8 @@ import { } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import type { ShortcutKey } from '$lib/utils/keyboard-shortcut.utils'; +import type { User } from '$lib/types/user.type'; +import { GLOBAL_SCOPE, SUDO_PERMISSION } from '$lib/types/role.type'; export type NavigationItem = { title: string; @@ -36,6 +38,25 @@ export type NavigationItem = { icon: any; shortcut?: ShortcutKey[]; items?: NavigationItem[]; + /** + * Permission(s) the user must hold to see this item. ANY-of semantics: if + * the user has at least one of the listed permissions on the relevant + * scope, the item is visible. Omit to make the item visible to every + * authenticated user. + */ + requiredPermission?: string | string[]; + /** + * Scope for the permission check. `'global'` checks the global permission + * set; `'env'` (default for resource items) accepts permissions scoped to + * the currently-selected environment. + */ + scope?: 'global' | 'env'; +}; + +export type RouteAccessRule = { + prefix: string; + perms: string[]; + scope: 'global' | 'env'; }; export type NavigationSections = { @@ -47,92 +68,299 @@ export type NavigationSections = { export const navigationItems: NavigationSections = { managementItems: [ - { title: m.dashboard_title(), url: '/dashboard', icon: DashboardIcon, shortcut: ['mod', '1'] }, - { title: m.projects_title(), url: '/projects', icon: ProjectsIcon, shortcut: ['mod', '2'] }, - { title: m.environments_title(), url: '/environments', icon: EnvironmentsIcon, shortcut: ['mod', '3'] }, + { + title: m.dashboard_title(), + url: '/dashboard', + icon: DashboardIcon, + shortcut: ['mod', '1'], + scope: 'env', + requiredPermission: 'dashboard:read' + }, + { + title: m.projects_title(), + url: '/projects', + icon: ProjectsIcon, + shortcut: ['mod', '2'], + scope: 'env', + requiredPermission: ['projects:list', 'projects:read'] + }, + { + title: m.environments_title(), + url: '/environments', + icon: EnvironmentsIcon, + shortcut: ['mod', '3'], + scope: 'global', + requiredPermission: ['environments:list', 'environments:read'] + }, { title: m.customize_title(), url: '/customize', icon: CustomizeIcon, shortcut: ['mod', '4'], + scope: 'global', + requiredPermission: 'customize:manage', items: [ - { title: m.templates_title(), url: '/customize/templates', icon: TemplateIcon }, - { title: m.registries_title(), url: '/customize/registries', icon: RegistryIcon }, - { title: m.variables_title(), url: '/customize/variables', icon: VariableIcon }, - { title: m.git_repositories_title(), url: '/customize/git-repositories', icon: GitBranchIcon } + { + title: m.templates_title(), + url: '/customize/templates', + icon: TemplateIcon, + scope: 'global', + requiredPermission: ['templates:list', 'templates:read'] + }, + { + title: m.registries_title(), + url: '/customize/registries', + icon: RegistryIcon, + scope: 'global', + requiredPermission: ['registries:list', 'registries:read'] + }, + { + title: m.variables_title(), + url: '/customize/variables', + icon: VariableIcon, + scope: 'global', + requiredPermission: ['templates:read'] + }, + { + title: m.git_repositories_title(), + url: '/customize/git-repositories', + icon: GitBranchIcon, + scope: 'global', + requiredPermission: ['git-repositories:list', 'git-repositories:read'] + } ] } ], resourceItems: [ - { title: m.containers_title(), url: '/containers', icon: ContainersIcon, shortcut: ['mod', '5'] }, + { + title: m.containers_title(), + url: '/containers', + icon: ContainersIcon, + shortcut: ['mod', '5'], + scope: 'env', + requiredPermission: ['containers:list', 'containers:read'] + }, { title: m.images_title(), url: '/images', icon: ImagesIcon, shortcut: ['mod', '6'], + scope: 'env', + requiredPermission: ['images:list', 'images:read'], items: [ - { title: m.builds(), url: '/images/builds', icon: HammerIcon }, - { title: m.vuln_title(), url: '/images/vulnerabilities', icon: ShieldAlertIcon } + { title: m.builds(), url: '/images/builds', icon: HammerIcon, scope: 'env', requiredPermission: 'images:build' }, + { + title: m.vuln_title(), + url: '/images/vulnerabilities', + icon: ShieldAlertIcon, + scope: 'env', + requiredPermission: 'vulnerabilities:read' + } ] }, - { title: m.images_updates(), url: '/updates', icon: UpdateIcon, shortcut: ['mod', 'u'] }, + { + title: m.images_updates(), + url: '/updates', + icon: UpdateIcon, + shortcut: ['mod', 'u'], + scope: 'env', + requiredPermission: 'image-updates:read' + }, { title: m.networks_title(), url: '/networks', icon: NetworksIcon, shortcut: ['mod', '7'], + scope: 'env', + requiredPermission: ['networks:list', 'networks:read'], items: [ - { title: m.ports_title(), url: '/ports', icon: HashIcon }, - { title: m.networks_topology_button(), url: '/networks/topology', icon: GitBranchIcon } + { title: m.ports_title(), url: '/ports', icon: HashIcon, scope: 'env', requiredPermission: 'containers:list' }, + { + title: m.networks_topology_button(), + url: '/networks/topology', + icon: GitBranchIcon, + scope: 'env', + requiredPermission: 'networks:read' + } ] }, - { title: m.volumes_title(), url: '/volumes', icon: VolumesIcon, shortcut: ['mod', '8'] } + { + title: m.volumes_title(), + url: '/volumes', + icon: VolumesIcon, + shortcut: ['mod', '8'], + scope: 'env', + requiredPermission: ['volumes:list', 'volumes:read'] + } ], swarmItems: [ - { title: 'Services', url: '/swarm/services', icon: DockIcon }, - { title: 'Nodes', url: '/swarm/nodes', icon: UsersIcon }, - { title: 'Tasks', url: '/swarm/tasks', icon: JobsIcon }, - { title: 'Stacks', url: '/swarm/stacks', icon: LayersIcon }, - { title: 'Cluster', url: '/swarm/cluster', icon: SettingsIcon }, - { title: 'Configs', url: '/swarm/configs', icon: TemplateIcon }, - { title: 'Secrets', url: '/swarm/secrets', icon: LockIcon } + { title: 'Services', url: '/swarm/services', icon: DockIcon, scope: 'env', requiredPermission: 'swarm:services' }, + { title: 'Nodes', url: '/swarm/nodes', icon: UsersIcon, scope: 'env', requiredPermission: 'swarm:nodes' }, + { title: 'Tasks', url: '/swarm/tasks', icon: JobsIcon, scope: 'env', requiredPermission: 'swarm:read' }, + { title: 'Stacks', url: '/swarm/stacks', icon: LayersIcon, scope: 'env', requiredPermission: 'swarm:stacks' }, + { title: 'Cluster', url: '/swarm/cluster', icon: SettingsIcon, scope: 'env', requiredPermission: 'swarm:read' }, + { title: 'Configs', url: '/swarm/configs', icon: TemplateIcon, scope: 'env', requiredPermission: 'swarm:configs' }, + { title: 'Secrets', url: '/swarm/secrets', icon: LockIcon, scope: 'env', requiredPermission: 'swarm:secrets' } ], settingsItems: [ { title: m.events_title(), url: '/events', icon: EventsIcon, - shortcut: ['mod', '9'] + shortcut: ['mod', '9'], + scope: 'global', + requiredPermission: 'events:read' }, { title: m.settings_title(), url: '/settings', icon: SettingsIcon, shortcut: ['mod', '0'], + scope: 'global', + requiredPermission: 'settings:read', items: [ - { title: m.api_key_page_title(), url: '/settings/api-keys', icon: ApiKeyIcon, shortcut: ['mod', 'shift', '1'] }, - { title: m.appearance_title(), url: '/settings/appearance', icon: AppearanceIcon, shortcut: ['mod', 'shift', '2'] }, - { title: m.webhook_page_title(), url: '/settings/webhooks', icon: GlobeIcon }, + { + title: m.api_key_page_title(), + url: '/settings/api-keys', + icon: ApiKeyIcon, + shortcut: ['mod', 'shift', '1'], + scope: 'global', + requiredPermission: 'apikeys:list' + }, + { + title: m.appearance_title(), + url: '/settings/appearance', + icon: AppearanceIcon, + shortcut: ['mod', 'shift', '2'], + scope: 'global', + requiredPermission: 'settings:read' + }, + { + title: m.webhook_page_title(), + url: '/settings/webhooks', + icon: GlobeIcon, + scope: 'env', + requiredPermission: 'webhooks:list' + }, { title: m.authentication_title(), url: '/settings/authentication', icon: LockIcon, - shortcut: ['mod', 'shift', '3'] + shortcut: ['mod', 'shift', '3'], + scope: 'global', + requiredPermission: 'settings:read' }, { title: m.notifications_title(), url: '/settings/notifications', icon: NotificationsIcon, - shortcut: ['mod', 'shift', '4'] + shortcut: ['mod', 'shift', '4'], + scope: 'env', + requiredPermission: 'notifications:manage' }, - { title: m.builds(), url: '/settings/builds', icon: HammerIcon, shortcut: ['mod', 'shift', '6'] }, - { title: m.timeouts_settings(), url: '/settings/timeouts', icon: JobsIcon, shortcut: ['mod', 'shift', '7'] }, - { title: m.users_title(), url: '/settings/users', icon: UsersIcon, shortcut: ['mod', 'shift', '8'] } + { + title: m.builds(), + url: '/settings/builds', + icon: HammerIcon, + shortcut: ['mod', 'shift', '6'], + scope: 'global', + requiredPermission: 'settings:read' + }, + { + title: m.timeouts_settings(), + url: '/settings/timeouts', + icon: JobsIcon, + shortcut: ['mod', 'shift', '7'], + scope: 'global', + requiredPermission: 'settings:read' + }, + { + title: m.users_title(), + url: '/settings/users', + icon: UsersIcon, + shortcut: ['mod', 'shift', '8'], + scope: 'global', + requiredPermission: ['users:read', 'users:list'] + }, + { + title: m.roles_title(), + url: '/settings/roles', + icon: ShieldAlertIcon, + scope: 'global', + requiredPermission: ['roles:read', 'roles:list'] + } ] } ] }; +// Keep the settings sub-navigation alphabetical regardless of the order +// entries are declared in the literal above. Sidebar, mobile nav, and the +// settings landing page all read from navigationItems.settingsItems, so a +// single sort here propagates everywhere. +{ + const settingsParent = navigationItems.settingsItems.find((item) => item.url === '/settings'); + if (settingsParent?.items) { + settingsParent.items.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })); + } +} + +// ---------- Permission-based filtering ---------- + +/** + * Filter a navigation tree to entries the user can reach. Empty parent groups + * (i.e. those whose children were all filtered out and which themselves have + * no required permission) are preserved; empty parent groups whose own + * permission check fails are removed. + * + * @param items navigation items to filter + * @param user the current user (or null when unauthenticated) + * @param currentEnvId the currently-selected env, for env-scoped checks + */ +export function filterByPermissions( + items: NavigationItem[] | undefined, + user: User | null, + currentEnvId: string | undefined +): NavigationItem[] { + if (!items) return []; + if (!user) return []; + const out: NavigationItem[] = []; + for (const item of items) { + if (!canSeeItem(item, user, currentEnvId)) continue; + if (item.items && item.items.length > 0) { + const filteredChildren = filterByPermissions(item.items, user, currentEnvId); + // Drop a parent group only when it has children declared but none + // survived the filter. A parent with NO children declared (a leaf + // link that happens to have an empty items array) stays. + if (filteredChildren.length === 0 && item.items.length > 0) continue; + out.push({ ...item, items: filteredChildren }); + } else { + out.push(item); + } + } + return out; +} + +function canSeeItem(item: NavigationItem, user: User, currentEnvId: string | undefined): boolean { + if (!item.requiredPermission) return true; + const required = Array.isArray(item.requiredPermission) ? item.requiredPermission : [item.requiredPermission]; + const scope = item.scope ?? 'env'; + const set = effectivePermissions(user, scope === 'env' ? currentEnvId : undefined); + if (set.has(SUDO_PERMISSION)) return true; + return required.some((p) => set.has(p)); +} + +function effectivePermissions(user: User, envId: string | undefined): Set { + const out = new Set(); + const global = user.permissionsByEnv?.[GLOBAL_SCOPE]; + if (global) for (const p of global) out.add(p); + if (envId && envId !== GLOBAL_SCOPE) { + const env = user.permissionsByEnv?.[envId]; + if (env) for (const p of env) out.add(p); + } + return out; +} + export function getSettingsSubpageUrlsInNavOrder(): string[] { const entry = navigationItems.settingsItems.find((item) => item.url === '/settings'); return entry?.items?.map((item) => item.url) ?? []; @@ -143,6 +371,49 @@ export function getCustomizeSubpageUrlsInNavOrder(): string[] { return entry?.items?.map((item) => item.url) ?? []; } +function routeAccessRulesForItems(items: NavigationItem[], out: RouteAccessRule[]): void { + for (const item of items) { + if (item.requiredPermission) { + out.push({ + prefix: item.url, + perms: Array.isArray(item.requiredPermission) ? item.requiredPermission : [item.requiredPermission], + scope: item.scope ?? 'env' + }); + } + if (item.items) { + routeAccessRulesForItems(item.items, out); + } + } +} + +export function getRouteAccessRules(): RouteAccessRule[] { + const out: RouteAccessRule[] = []; + routeAccessRulesForItems(navigationItems.managementItems, out); + routeAccessRulesForItems(navigationItems.resourceItems, out); + out.push({ prefix: '/swarm', perms: ['swarm:read'], scope: 'env' }); + routeAccessRulesForItems(navigationItems.swarmItems, out); + routeAccessRulesForItems(navigationItems.settingsItems, out); + return out; +} + +export function getRouteFallbackRules(): RouteAccessRule[] { + return getRouteAccessRules().filter((rule) => { + return ( + rule.prefix === '/dashboard' || + rule.prefix === '/containers' || + rule.prefix === '/projects' || + rule.prefix === '/images' || + rule.prefix === '/volumes' || + rule.prefix === '/networks' || + rule.prefix === '/swarm/services' || + rule.prefix === '/swarm/stacks' || + rule.prefix === '/swarm/cluster' || + rule.prefix === '/events' || + rule.prefix === '/settings' + ); + }); +} + export const defaultMobilePinnedItems: NavigationItem[] = [ navigationItems.managementItems[0]!, navigationItems.managementItems[1]!, diff --git a/frontend/src/lib/services/oidc-mapping-service.ts b/frontend/src/lib/services/oidc-mapping-service.ts new file mode 100644 index 0000000000..74c72184e0 --- /dev/null +++ b/frontend/src/lib/services/oidc-mapping-service.ts @@ -0,0 +1,22 @@ +import BaseAPIService from './api-service'; +import type { OidcRoleMapping, CreateOidcRoleMapping, UpdateOidcRoleMapping } from '$lib/types/role.type'; + +export default class OidcMappingAPIService extends BaseAPIService { + async list(): Promise { + return this.handleResponse(this.api.get('/oidc/role-mappings')) as Promise; + } + + async create(mapping: CreateOidcRoleMapping): Promise { + return this.handleResponse(this.api.post('/oidc/role-mappings', mapping)) as Promise; + } + + async update(id: string, mapping: UpdateOidcRoleMapping): Promise { + return this.handleResponse(this.api.put(`/oidc/role-mappings/${id}`, mapping)) as Promise; + } + + async delete(id: string): Promise { + return this.handleResponse(this.api.delete(`/oidc/role-mappings/${id}`)) as Promise; + } +} + +export const oidcMappingService = new OidcMappingAPIService(); diff --git a/frontend/src/lib/services/role-service.ts b/frontend/src/lib/services/role-service.ts new file mode 100644 index 0000000000..57dcf49f0c --- /dev/null +++ b/frontend/src/lib/services/role-service.ts @@ -0,0 +1,51 @@ +import BaseAPIService from './api-service'; +import type { Role, CreateRole, UpdateRole, RoleAssignment, SetUserAssignments, PermissionsManifest } from '$lib/types/role.type'; +import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import { transformPaginationParams } from '$lib/utils/params.util'; + +export default class RoleAPIService extends BaseAPIService { + async getRoles(options?: SearchPaginationSortRequest): Promise> { + const params = transformPaginationParams(options); + const res = await this.api.get('/roles', { params }); + return res.data; + } + + async getAll(): Promise { + // Unpaginated convenience for selects. Backend caps at a generous + // limit (1000) which is well above any realistic role count. + const res = await this.api.get('/roles', { + params: { limit: 1000, sort: 'name', order: 'asc' } + }); + return res.data.data ?? []; + } + + async get(id: string): Promise { + return this.handleResponse(this.api.get(`/roles/${id}`)) as Promise; + } + + async create(role: CreateRole): Promise { + return this.handleResponse(this.api.post('/roles', role)) as Promise; + } + + async update(id: string, role: UpdateRole): Promise { + return this.handleResponse(this.api.put(`/roles/${id}`, role)) as Promise; + } + + async delete(id: string): Promise { + return this.handleResponse(this.api.delete(`/roles/${id}`)) as Promise; + } + + async getPermissionsManifest(): Promise { + return this.handleResponse(this.api.get('/roles/available-permissions')) as Promise; + } + + async getUserAssignments(userId: string): Promise { + return this.handleResponse(this.api.get(`/users/${userId}/role-assignments`)) as Promise; + } + + async setUserAssignments(userId: string, payload: SetUserAssignments): Promise { + return this.handleResponse(this.api.put(`/users/${userId}/role-assignments`, payload)) as Promise; + } +} + +export const roleService = new RoleAPIService(); diff --git a/frontend/src/lib/stores/user-store.ts b/frontend/src/lib/stores/user-store.ts index e8ddf208ee..27ee988940 100644 --- a/frontend/src/lib/stores/user-store.ts +++ b/frontend/src/lib/stores/user-store.ts @@ -1,4 +1,5 @@ import type { User } from '$lib/types/user.type'; +import { GLOBAL_SCOPE, SUDO_PERMISSION, BUILT_IN_ROLE_ADMIN } from '$lib/types/role.type'; import { writable, get } from 'svelte/store'; import { setLocale } from '$lib/utils/locale.util'; @@ -15,14 +16,56 @@ const clearUser = () => { userStore.set(null); }; -const isAdmin = () => { +/** + * Build the effective permission Set for the given environment. Includes + * global permissions plus permissions scoped to `envId`. + * + * Pass `undefined` for `envId` to get only the global set (use this for + * checking org-level permissions, or as a fallback before an env is selected). + */ +const permissions = (envId?: string): Set => { const user = get(userStore); - return !!user?.roles?.includes('admin'); + if (!user?.permissionsByEnv) return new Set(); + const out = new Set(); + const global = user.permissionsByEnv[GLOBAL_SCOPE]; + if (global) for (const p of global) out.add(p); + if (envId && envId !== GLOBAL_SCOPE) { + const env = user.permissionsByEnv[envId]; + if (env) for (const p of env) out.add(p); + } + return out; +}; + +/** Returns true if the caller may perform `perm`. Sudo callers (`*` in global) always return true. */ +const hasPermission = (perm: string, envId?: string): boolean => { + const set = permissions(envId); + if (set.has(SUDO_PERMISSION)) return true; + return set.has(perm); +}; + +/** Returns true if the caller has ANY of the supplied permissions. */ +const hasAnyPermission = (perms: string[], envId?: string): boolean => { + if (perms.length === 0) return true; + const set = permissions(envId); + if (set.has(SUDO_PERMISSION)) return true; + return perms.some((p) => set.has(p)); +}; + +/** Returns true if the caller holds the built-in Admin role globally OR is a sudo caller. */ +const isGlobalAdmin = (): boolean => { + const user = get(userStore); + if (!user) return false; + const global = user.permissionsByEnv?.[GLOBAL_SCOPE]; + if (global?.includes(SUDO_PERMISSION)) return true; + return !!user.roleAssignments?.some((a) => a.roleId === BUILT_IN_ROLE_ADMIN && !a.environmentId); }; export default { subscribe: userStore.subscribe, setUser, clearUser, - isAdmin + permissions, + hasPermission, + hasAnyPermission, + isGlobalAdmin }; diff --git a/frontend/src/lib/types/api-key.type.ts b/frontend/src/lib/types/api-key.type.ts index 0ebc124b1e..c6d7303374 100644 --- a/frontend/src/lib/types/api-key.type.ts +++ b/frontend/src/lib/types/api-key.type.ts @@ -1,3 +1,5 @@ +import type { ApiKeyPermissionGrant } from '$lib/types/role.type'; + export type ApiKey = { id: string; name: string; @@ -5,10 +7,12 @@ export type ApiKey = { keyPrefix: string; userId: string; isStatic: boolean; + isBootstrap: boolean; expiresAt?: string; lastUsedAt?: string; createdAt: string; updatedAt?: string; + permissions?: ApiKeyPermissionGrant[]; }; export type ApiKeyCreated = ApiKey & { @@ -19,10 +23,12 @@ export type CreateApiKey = { name: string; description?: string; expiresAt?: string; + permissions: ApiKeyPermissionGrant[]; }; export type UpdateApiKey = { name?: string; description?: string; expiresAt?: string; + permissions?: ApiKeyPermissionGrant[]; }; diff --git a/frontend/src/lib/types/role.type.ts b/frontend/src/lib/types/role.type.ts new file mode 100644 index 0000000000..4ae7c0f4f4 --- /dev/null +++ b/frontend/src/lib/types/role.type.ts @@ -0,0 +1,106 @@ +// Public RBAC types — mirrors backend types/role/role.go. + +export type RoleScope = 'global' | 'env'; + +/** A named permission set. Built-in roles are immutable. */ +export type Role = { + id: string; + name: string; + description?: string; + permissions: string[]; + builtIn: boolean; + assignedUserCount: number; + createdAt: string; + updatedAt?: string; +}; + +export type CreateRole = { + name: string; + description?: string; + permissions: string[]; +}; + +export type UpdateRole = { + name: string; + description?: string; + permissions: string[]; +}; + +/** One row in a user's role assignments. EnvironmentID omitted = global scope. */ +export type RoleAssignment = { + id: string; + userId: string; + roleId: string; + environmentId?: string; + source: 'manual' | 'oidc'; + createdAt: string; +}; + +/** Compact assignment shape returned on the User payload. */ +export type RoleAssignmentSummary = { + roleId: string; + environmentId?: string; + source: 'manual' | 'oidc'; +}; + +/** Payload for replacing a user's manual role assignments. */ +export type SetUserAssignments = { + assignments: { roleId: string; environmentId?: string }[]; +}; + +export type OidcMappingSource = 'manual' | 'env'; + +export type OidcRoleMapping = { + id: string; + claimValue: string; + roleId: string; + environmentId?: string; + source: OidcMappingSource; + createdAt: string; + updatedAt?: string; +}; + +export type CreateOidcRoleMapping = { + claimValue: string; + roleId: string; + environmentId?: string; +}; + +export type UpdateOidcRoleMapping = CreateOidcRoleMapping; + +/** The permission manifest from GET /roles/available-permissions. */ +export type PermissionsManifest = { + resources: PermissionResource[]; +}; + +export type PermissionResource = { + key: string; + label: string; + scope: RoleScope; + actions: PermissionAction[]; +}; + +export type PermissionAction = { + key: string; + permission: string; + label: string; + description?: string; +}; + +/** One permission grant on an API key. EnvironmentID omitted = global grant. */ +export type ApiKeyPermissionGrant = { + permission: string; + environmentId?: string; +}; + +/** Built-in role IDs, stable across migrations. */ +export const BUILT_IN_ROLE_ADMIN = 'role_admin'; +export const BUILT_IN_ROLE_EDITOR = 'role_editor'; +export const BUILT_IN_ROLE_DEPLOYER = 'role_deployer'; +export const BUILT_IN_ROLE_VIEWER = 'role_viewer'; + +/** Sentinel used in PermissionsByEnv['global'] to mean "every permission". */ +export const SUDO_PERMISSION = '*'; + +/** Map key used for global-scope permissions in PermissionsByEnv. */ +export const GLOBAL_SCOPE = 'global'; diff --git a/frontend/src/lib/types/settings.type.ts b/frontend/src/lib/types/settings.type.ts index 10d1c4590f..03b2ce2a53 100644 --- a/frontend/src/lib/types/settings.type.ts +++ b/frontend/src/lib/types/settings.type.ts @@ -71,8 +71,7 @@ export type Settings = { oidcClientSecret?: string; oidcIssuerUrl: string; oidcScopes: string; - oidcAdminClaim: string; - oidcAdminValue: string; + oidcGroupsClaim: string; oidcSkipTlsVerify: boolean; oidcAutoRedirectToProvider: boolean; oidcMergeAccounts: boolean; diff --git a/frontend/src/lib/types/user.type.ts b/frontend/src/lib/types/user.type.ts index 5d75bf3146..45a54507a3 100644 --- a/frontend/src/lib/types/user.type.ts +++ b/frontend/src/lib/types/user.type.ts @@ -1,4 +1,5 @@ import type { Locale } from '$lib/paraglide/runtime'; +import type { RoleAssignmentSummary } from '$lib/types/role.type'; export type User = { id: string; @@ -6,7 +7,15 @@ export type User = { passwordHash?: string; displayName?: string; email?: string; - roles: string[]; + /** Role assignments held by this user. */ + roleAssignments: RoleAssignmentSummary[]; + /** + * Server-resolved effective permissions, keyed by environment ID. The + * 'global' key holds permissions that apply across every environment AND + * to org-level endpoints. The value `['*']` under 'global' is a sentinel + * meaning "every permission" (sudo callers). + */ + permissionsByEnv: Record; canDelete?: boolean; createdAt: string; lastLogin?: string; @@ -18,8 +27,15 @@ export type User = { export type CreateUser = Omit< User, - 'id' | 'createdAt' | 'updatedAt' | 'lastLogin' | 'oidcSubjectId' | 'passwordHash' | 'requiresPasswordChange' | 'roles' + | 'id' + | 'createdAt' + | 'updatedAt' + | 'lastLogin' + | 'oidcSubjectId' + | 'passwordHash' + | 'requiresPasswordChange' + | 'roleAssignments' + | 'permissionsByEnv' > & { password: string; - roles?: string[]; }; diff --git a/frontend/src/lib/utils/permissions.util.ts b/frontend/src/lib/utils/permissions.util.ts new file mode 100644 index 0000000000..c755220695 --- /dev/null +++ b/frontend/src/lib/utils/permissions.util.ts @@ -0,0 +1,63 @@ +import userStore from '$lib/stores/user-store'; +import { environmentStore } from '$lib/stores/environment.store.svelte'; +import { GLOBAL_SCOPE } from '$lib/types/role.type'; +import { get } from 'svelte/store'; +import type { User } from '$lib/types/user.type'; + +/** + * Resolve the env ID to use for an RBAC check. When the caller doesn't pass + * one, fall back to the currently-selected environment from the store. + * Returns `undefined` when no env is selected — callers should treat that + * as "global-only check" (env-scoped permissions will deny). + */ +function resolveEnvId(envId?: string): string | undefined { + if (envId) return envId; + const selected = environmentStore.selected; + if (!selected?.id) return undefined; + return selected.id; +} + +/** + * Check whether the current user holds `perm` on the given environment. + * + * Reactive callers (inside `.svelte` files) should wrap this in `$derived` + * along with a read of `environmentStore.selected?.id` so the value re-computes + * when the env switches. + */ +export function hasPermission(perm: string, envId?: string): boolean { + return userStore.hasPermission(perm, resolveEnvId(envId)); +} + +/** Returns true if the user has ANY of the supplied permissions on `envId`. */ +export function hasAnyPermission(perms: string[], envId?: string): boolean { + return userStore.hasAnyPermission(perms, resolveEnvId(envId)); +} + +/** Returns the full effective permission set for the given env (global ∪ env). */ +export function permissions(envId?: string): Set { + return userStore.permissions(resolveEnvId(envId)); +} + +/** Returns true if the caller is a global admin (or sudo). */ +export function isGlobalAdmin(): boolean { + return userStore.isGlobalAdmin(); +} + +/** + * Returns true if the user holds AT LEAST one permission on any environment + * (or globally). Useful for the "no access at all" fallback redirect. + */ +export function hasAnyAccess(user: User | null): boolean { + if (!user?.permissionsByEnv) return false; + for (const perms of Object.values(user.permissionsByEnv)) { + if (perms.length > 0) return true; + } + return false; +} + +// Keep imports tree-shake-friendly: re-export only what callers need. +export { GLOBAL_SCOPE }; + +// `get` is imported above to satisfy the (now-removed) hasAnyAccess fallback; +// kept exported so callers can grab the raw store snapshot if needed. +export { get }; diff --git a/frontend/src/lib/utils/redirect.util.ts b/frontend/src/lib/utils/redirect.util.ts index efed3c49b8..edd6fdfed1 100644 --- a/frontend/src/lib/utils/redirect.util.ts +++ b/frontend/src/lib/utils/redirect.util.ts @@ -1,4 +1,6 @@ import type { User } from '$lib/types/user.type'; +import { GLOBAL_SCOPE, SUDO_PERMISSION, BUILT_IN_ROLE_ADMIN } from '$lib/types/role.type'; +import { getRouteAccessRules, getRouteFallbackRules, type RouteAccessRule } from '$lib/config/navigation-config'; const PROTECTED_PREFIXES = [ '/dashboard', @@ -10,22 +12,97 @@ const PROTECTED_PREFIXES = [ '/images', '/volumes', '/networks', - '/settings' + '/ports', + '/settings', + '/swarm', + '/updates' ]; const UNAUTHENTICATED_ONLY_PREFIXES = ['/login', '/oidc/login', '/oidc/callback', '/auth/oidc/callback', '/img', '/favicon.ico']; -const ADMIN_ONLY_PREFIXES = ['/settings', '/events', '/customize/registries', '/customize/variables']; +function isGlobalAdmin(user: User): boolean { + const global = user.permissionsByEnv?.[GLOBAL_SCOPE]; + if (global?.includes(SUDO_PERMISSION)) return true; + return !!user.roleAssignments?.some((a) => a.roleId === BUILT_IN_ROLE_ADMIN && !a.environmentId); +} /** - * Checks if a path matches a prefix exactly or as a parent directory + * Exported global-admin check for use in `+page.ts` load functions, where the + * user is available via `await parent()` but the store-backed helper in + * `permissions.util.ts` is not appropriate (load runs before stores hydrate). */ +export function userIsGlobalAdmin(user: User | null | undefined): boolean { + return !!user && isGlobalAdmin(user); +} + +/** + * True for routes reserved for global admins. Role creation/editing and OIDC + * mapping management are intentionally not delegatable to non-admin users — + * the matching backend routes are gated by RequireGlobalAdmin. Note that the + * bare `/settings/roles` list is still reachable for readers; only the + * `/new` and `/` subroutes (the editor) are admin-only. + */ +function isAdminOnlyRoute(path: string): boolean { + return path === '/settings/roles/new' || /^\/settings\/roles\/[^/]+/.test(path); +} + +/** Checks if a path matches a prefix exactly or as a parent directory. */ const matchesAny = (path: string, prefixes: string[]) => prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); -export function getAuthRedirectPath(path: string, user: User | null) { +/** Returns the permission set the user effectively holds at `envId` (global ∪ env). */ +function permissionsForEnv(user: User, envId?: string): Set { + const out = new Set(); + const global = user.permissionsByEnv?.[GLOBAL_SCOPE]; + if (global) for (const p of global) out.add(p); + if (envId && envId !== GLOBAL_SCOPE) { + const env = user.permissionsByEnv?.[envId]; + if (env) for (const p of env) out.add(p); + } + return out; +} + +/** Returns true if the user can satisfy any of `perms` at the given env scope. */ +function userCanReach(user: User, perms: string[], scope: 'global' | 'env', envId?: string): boolean { + const set = permissionsForEnv(user, scope === 'env' ? envId : undefined); + if (set.has(SUDO_PERMISSION)) return true; + return perms.some((p) => set.has(p)); +} + +/** Returns true if the user holds any permission anywhere. */ +function hasAnyAccess(user: User): boolean { + if (!user.permissionsByEnv) return false; + for (const perms of Object.values(user.permissionsByEnv)) { + if (perms.length > 0) return true; + } + return false; +} + +/** + * Pick a fallback route the user CAN reach. Walks the gated routes in a + * sensible priority order (dashboard first, then resources, then settings) + * and returns the first one the user satisfies. Returns `/no-access` if + * nothing matches — that page is always reachable. + */ +function pickFallbackRoute(user: User, envId?: string): string { + for (const rule of getRouteFallbackRules()) { + if (userCanReach(user, rule.perms, rule.scope, envId)) { + return rule.prefix; + } + } + return '/no-access'; +} + +/** + * getAuthRedirectPath decides where to send the caller based on the current + * path, authentication state, and effective permissions. Returns null when + * no redirect is needed. + * + * @param envId the currently-selected environment ID; used for env-scoped + * permission checks. Pass undefined if no env is selected. + */ +export function getAuthRedirectPath(path: string, user: User | null, envId?: string): string | null { const isSignedIn = !!user; - const isAdmin = user?.roles.includes('admin'); // 1. Handle root path if (path === '/') { @@ -42,9 +119,33 @@ export function getAuthRedirectPath(path: string, user: User | null) { return '/dashboard'; } - // 4. Redirect non-admins away from restricted management areas - if (isSignedIn && !isAdmin && matchesAny(path, ADMIN_ONLY_PREFIXES)) { - return '/dashboard'; + if (!isSignedIn || !user) return null; + + // 4. Users with zero permissions land on /no-access (unless already there). + if (path !== '/no-access' && !hasAnyAccess(user)) { + return '/no-access'; + } + + // 5. Admin-only routes (role editor) bounce non-admins back to the roles + // list — they can still read role definitions, just not edit them. + if (isAdminOnlyRoute(path) && !isGlobalAdmin(user)) { + return '/settings/roles'; + } + + // 6. Per-route permission gating. Walk the most-specific prefix first. + const sorted: RouteAccessRule[] = [...getRouteAccessRules()].sort((a, b) => b.prefix.length - a.prefix.length); + for (const rule of sorted) { + if (matchesAny(path, [rule.prefix])) { + if (!userCanReach(user, rule.perms, rule.scope, envId)) { + // Don't fall back to a route the user also can't reach — that + // produces an effective loop where every redirect target bounces + // to the same place. Pick the first reachable route in priority + // order, or /no-access if nothing fits. + const fallback = pickFallbackRoute(user, envId); + return fallback === path ? '/no-access' : fallback; + } + break; + } } return null; diff --git a/frontend/src/lib/utils/settings.util.ts b/frontend/src/lib/utils/settings.util.ts index e5aec036fe..ac975f81b3 100644 --- a/frontend/src/lib/utils/settings.util.ts +++ b/frontend/src/lib/utils/settings.util.ts @@ -31,8 +31,7 @@ const LOCAL_SETTING_KEYS = new Set([ 'oidcClientSecret', 'oidcIssuerUrl', 'oidcScopes', - 'oidcAdminClaim', - 'oidcAdminValue', + 'oidcGroupsClaim', 'oidcProviderName', 'oidcProviderLogoUrl', 'edgeMTLSManagerCAAvailable' diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 41e73f3609..adb3a6fc20 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -10,7 +10,7 @@ import { getEffectiveNavigationSettings, navigationSettingsOverridesStore } from '$lib/utils/navigation.utils'; import { browser } from '$app/environment'; import { environmentStore } from '$lib/stores/environment.store.svelte'; - import { navigationItems, getManagementItems, type NavigationItem } from '$lib/config/navigation-config'; + import { navigationItems, getManagementItems, filterByPermissions, type NavigationItem } from '$lib/config/navigation-config'; import { isEditableTarget, matchesShortcutEvent } from '$lib/utils/keyboard-shortcut.utils'; import { cn } from '$lib/utils'; let { data, children }: LayoutProps = $props(); @@ -29,17 +29,16 @@ return getEffectiveNavigationSettings(); }); const navigationMode = $derived(navigationSettings.mode); - const isAdmin = $derived(!!user?.roles?.includes('admin')); const currentEnvId = $derived(environmentStore.selected?.id || '0'); const managementItems = $derived(getManagementItems(currentEnvId)); - const settingsShortcutItems = $derived.by(() => (isAdmin ? (navigationItems.settingsItems ?? []) : [])); + const settingsShortcutItems = $derived(filterByPermissions(navigationItems.settingsItems, user ?? null, currentEnvId)); const shortcutItems = $derived.by(() => { const items: NavigationItem[] = [...managementItems, ...navigationItems.resourceItems, ...settingsShortcutItems]; return flattenNavigationItems(items).filter((item) => item.shortcut?.length); }); $effect(() => { - const redirectPath = getAuthRedirectPath(page.url.pathname, user); + const redirectPath = getAuthRedirectPath(page.url.pathname, user, currentEnvId); if (redirectPath) { goto(redirectPath); } diff --git a/frontend/src/routes/(app)/containers/+page.svelte b/frontend/src/routes/(app)/containers/+page.svelte index 3f2caace26..df54f360c1 100644 --- a/frontend/src/routes/(app)/containers/+page.svelte +++ b/frontend/src/routes/(app)/containers/+page.svelte @@ -8,8 +8,7 @@ import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index'; import { environmentStore } from '$lib/stores/environment.store.svelte'; - import userStore from '$lib/stores/user-store'; - import { fromStore } from 'svelte/store'; + import { hasPermission } from '$lib/utils/permissions.util'; import type { ContainerCreateRequest, ContainerStatusCounts } from '$lib/types/container.type'; import { createMutation } from '@tanstack/svelte-query'; import { BoxIcon } from '$lib/icons'; @@ -105,8 +104,7 @@ const containerStatusCounts = $derived(containers.counts ?? countsFallback); - const storeUser = fromStore(userStore); - const isAdmin = $derived(!!storeUser.current?.roles?.includes('admin')); + const canAutoUpdate = $derived(hasPermission('containers:autoupdate', envId)); const actionButtons: ActionButton[] = $derived( [ @@ -118,7 +116,7 @@ loading: createContainerMutation.isPending, disabled: createContainerMutation.isPending }, - isAdmin + canAutoUpdate ? { id: 'check-updates', action: 'update', diff --git a/frontend/src/routes/(app)/containers/[containerId]/+page.svelte b/frontend/src/routes/(app)/containers/[containerId]/+page.svelte index 9d732ca51b..fb89fc1e76 100644 --- a/frontend/src/routes/(app)/containers/[containerId]/+page.svelte +++ b/frontend/src/routes/(app)/containers/[containerId]/+page.svelte @@ -47,6 +47,7 @@ import type { IncludeFile } from '$lib/types/project.type'; import { projectService } from '$lib/services/project-service'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); let container = $derived(data?.container as ContainerDetailsDto); let stats = $state(null as ContainerStatsType | null); @@ -140,8 +141,11 @@ ); const showNetworkTab = $derived(hasNetworks || hasPorts); const hasMounts = $derived(!!(container?.mounts && container.mounts.length > 0)); + const currentEnvId = $derived(environmentStore.selected?.id); + const canViewLogs = $derived(hasPermission('containers:logs', currentEnvId)); + const canExecShell = $derived(hasPermission('containers:exec', currentEnvId)); const showStats = $derived(!!container?.state?.running); - const showShell = $derived(!!container?.state?.running); + const showShell = $derived(!!container?.state?.running && canExecShell); const hasHealthcheck = $derived( !!(container?.config?.healthcheck?.test && container.config.healthcheck.test.length > 0) || !!container?.state?.health ); @@ -213,7 +217,7 @@ const tabItems = $derived([ { value: 'overview', label: m.common_overview(), icon: ContainersIcon }, ...(showStats ? [{ value: 'stats', label: m.containers_nav_metrics(), icon: StatsIcon }] : []), - { value: 'logs', label: m.containers_nav_logs(), icon: FileTextIcon }, + ...(canViewLogs ? [{ value: 'logs', label: m.containers_nav_logs(), icon: FileTextIcon }] : []), ...(showShell ? [{ value: 'shell', label: m.common_shell(), icon: TerminalIcon }] : []), ...(hasHealthcheck ? [{ value: 'healthcheck', label: m.containers_nav_healthcheck(), icon: HealthIcon }] : []), ...(showConfiguration ? [{ value: 'config', label: m.common_configuration(), icon: SettingsIcon }] : []), diff --git a/frontend/src/routes/(app)/containers/container-table.svelte b/frontend/src/routes/(app)/containers/container-table.svelte index 271c0c59c6..aa45844688 100644 --- a/frontend/src/routes/(app)/containers/container-table.svelte +++ b/frontend/src/routes/(app)/containers/container-table.svelte @@ -26,8 +26,7 @@ import ContainerStatsSync from './components/container-stats-sync.svelte'; import ContainerStatsCell from './components/container-stats-cell.svelte'; import { environmentStore } from '$lib/stores/environment.store.svelte'; - import userStore from '$lib/stores/user-store'; - import { fromStore } from 'svelte/store'; + import { hasPermission } from '$lib/utils/permissions.util'; import IconImage from '$lib/components/icon-image.svelte'; import { getArcaneIconUrlFromLabels } from '$lib/utils/arcane-labels'; import { createContainerActions } from './container-table.actions'; @@ -149,9 +148,6 @@ Object.values(actionStatus).some((status) => status !== '') || Object.values(isBulkLoading).some((loading) => loading) ); - const storeUser = fromStore(userStore); - const isAdmin = $derived(!!storeUser.current?.roles?.includes('admin')); - let mobileFieldVisibility = $state>({}); let customSettings = $state>({}); let showInternal = $derived.by(() => { @@ -184,6 +180,7 @@ }); const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canUpdateContainers = $derived(hasPermission('containers:autoupdate', currentEnvId)); onMount(() => { collapsedGroupsState = new PersistedState>('container-groups-collapsed', {}); @@ -426,7 +423,7 @@ icon={StopIcon} /> {/if} - {#if !status && item.updateInfo?.hasUpdate && isAdmin} + {#if !status && item.updateInfo?.hasUpdate && canUpdateContainers} handleUpdateContainer(item) : undefined} + onUpdateContainer={canUpdateContainers ? () => handleUpdateContainer(item) : undefined} debugHasUpdate={false} /> {/snippet} @@ -592,7 +589,7 @@ imageId={item.imageId} repo={imageRef.repo} tag={imageRef.tag} - onUpdateContainer={isAdmin ? () => handleUpdateContainer(item) : undefined} + onUpdateContainer={canUpdateContainers ? () => handleUpdateContainer(item) : undefined} debugHasUpdate={false} />
@@ -624,7 +621,7 @@ - {#if item.updateInfo?.hasUpdate && isAdmin} + {#if item.updateInfo?.hasUpdate && canUpdateContainers} handleUpdateContainer(item)} disabled={status === 'updating' || isAnyLoading}> {#if status === 'updating'} diff --git a/frontend/src/routes/(app)/customize/git-repositories/+page.svelte b/frontend/src/routes/(app)/customize/git-repositories/+page.svelte index 83ff2df123..d804441f27 100644 --- a/frontend/src/routes/(app)/customize/git-repositories/+page.svelte +++ b/frontend/src/routes/(app)/customize/git-repositories/+page.svelte @@ -9,6 +9,7 @@ import { gitRepositoryService } from '$lib/services/git-repository-service'; import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton } from '$lib/layouts/index.js'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); @@ -74,22 +75,28 @@ } } - const actionButtons: ActionButton[] = [ - { - id: 'create', - action: 'create', - label: m.common_add_button({ resource: m.resource_repository_cap() }), - onclick: openCreateRepositoryDialog - }, - { + const canCreateRepository = $derived(hasPermission('git-repositories:create')); + + const actionButtons: ActionButton[] = $derived.by(() => { + const buttons: ActionButton[] = []; + if (canCreateRepository) { + buttons.push({ + id: 'create', + action: 'create', + label: m.common_add_button({ resource: m.resource_repository_cap() }), + onclick: openCreateRepositoryDialog + }); + } + buttons.push({ id: 'refresh', action: 'restart', label: m.common_refresh(), onclick: refreshRepositories, loading: isLoading.refresh, disabled: isLoading.refresh - } - ]; + }); + return buttons; + }); diff --git a/frontend/src/routes/(app)/customize/git-repositories/repository-table.svelte b/frontend/src/routes/(app)/customize/git-repositories/repository-table.svelte index c12151a6c1..963df1418d 100644 --- a/frontend/src/routes/(app)/customize/git-repositories/repository-table.svelte +++ b/frontend/src/routes/(app)/customize/git-repositories/repository-table.svelte @@ -23,6 +23,8 @@ ExternalLinkIcon as LinkIcon, EllipsisIcon } from '$lib/icons'; + import { hasPermission } from '$lib/utils/permissions.util'; + import IfPermitted from '$lib/components/if-permitted.svelte'; type FieldVisibility = Record; @@ -43,6 +45,8 @@ testing: false }); + const canDeleteRepository = $derived(hasPermission('git-repositories:delete')); + const bulkActions = $derived.by(() => [ { id: 'remove', @@ -50,7 +54,7 @@ action: 'remove', onClick: handleDeleteSelected, loading: isLoading.removing, - disabled: isLoading.removing, + disabled: !canDeleteRepository || isLoading.removing, icon: Trash2Icon } ]); @@ -259,26 +263,32 @@ - handleTest(item.id, item.name)} disabled={isLoading.testing}> - - {m.git_repository_test_connection()} - + + handleTest(item.id, item.name)} disabled={isLoading.testing}> + + {m.git_repository_test_connection()} + + - onEditRepository(item)}> - - {m.common_edit()} - + + onEditRepository(item)}> + + {m.common_edit()} + + - + {#if canDeleteRepository} + - handleDeleteOne(item.id, item.name)} - disabled={isLoading.removing} - > - - {m.common_remove()} - + handleDeleteOne(item.id, item.name)} + disabled={isLoading.removing} + > + + {m.common_remove()} + + {/if} diff --git a/frontend/src/routes/(app)/customize/registries/+page.svelte b/frontend/src/routes/(app)/customize/registries/+page.svelte index dfe31f65d6..a7fd55f508 100644 --- a/frontend/src/routes/(app)/customize/registries/+page.svelte +++ b/frontend/src/routes/(app)/customize/registries/+page.svelte @@ -13,6 +13,7 @@ import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton } from '$lib/layouts/index.js'; import { createQuery } from '@tanstack/svelte-query'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); @@ -89,28 +90,35 @@ } } - const actionButtons: ActionButton[] = [ - { - id: 'info', - action: 'inspect', - label: m.registries_info_title(), - onclick: () => (isInfoDialogOpen = true) - }, - { - id: 'create', - action: 'create', - label: m.common_add_button({ resource: m.resource_registry_cap() }), - onclick: openCreateRegistryDialog - }, - { + const canCreateRegistry = $derived(hasPermission('registries:create')); + + const actionButtons: ActionButton[] = $derived.by(() => { + const buttons: ActionButton[] = [ + { + id: 'info', + action: 'inspect', + label: m.registries_info_title(), + onclick: () => (isInfoDialogOpen = true) + } + ]; + if (canCreateRegistry) { + buttons.push({ + id: 'create', + action: 'create', + label: m.common_add_button({ resource: m.resource_registry_cap() }), + onclick: openCreateRegistryDialog + }); + } + buttons.push({ id: 'refresh', action: 'restart', label: m.common_refresh(), onclick: refreshRegistries, loading: isLoading.refresh, disabled: isLoading.refresh - } - ]; + }); + return buttons; + }); diff --git a/frontend/src/routes/(app)/customize/registries/registry-table.svelte b/frontend/src/routes/(app)/customize/registries/registry-table.svelte index 57f72089a7..d4ea388fbb 100644 --- a/frontend/src/routes/(app)/customize/registries/registry-table.svelte +++ b/frontend/src/routes/(app)/customize/registries/registry-table.svelte @@ -16,6 +16,8 @@ import { m } from '$lib/paraglide/messages'; import { containerRegistryService } from '$lib/services/container-registry-service'; import { RegistryIcon, UserIcon, ExternalLinkIcon, EditIcon, TrashIcon, TestIcon, EllipsisIcon } from '$lib/icons'; + import { hasPermission } from '$lib/utils/permissions.util'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { registries = $bindable(), @@ -34,6 +36,8 @@ let removingId = $state(null); let testingId = $state(null); + const canDeleteRegistry = $derived(hasPermission('registries:delete')); + function maskAccessKeyId(keyId: string | undefined): string { if (!keyId) return m.common_na(); if (keyId.length <= 4) return keyId; @@ -199,7 +203,7 @@ action: 'remove', onClick: handleDeleteSelected, loading: isLoading.removing, - disabled: isLoading.removing, + disabled: !canDeleteRegistry || isLoading.removing, icon: TrashIcon } ]); @@ -294,34 +298,40 @@ - handleTest(item.id, item.url)} disabled={testingId === item.id}> - {#if testingId === item.id} - - {:else} - - {/if} - {m.registries_test_connection()} - + + handleTest(item.id, item.url)} disabled={testingId === item.id}> + {#if testingId === item.id} + + {:else} + + {/if} + {m.registries_test_connection()} + + - onEditRegistry(item)}> - - {m.common_edit()} - + + onEditRegistry(item)}> + + {m.common_edit()} + + - + {#if canDeleteRegistry} + - handleDeleteOne(item.id, item.url)} - disabled={removingId === item.id} - > - {#if removingId === item.id} - - {:else} - - {/if} - {m.common_remove()} - + handleDeleteOne(item.id, item.url)} + disabled={removingId === item.id} + > + {#if removingId === item.id} + + {:else} + + {/if} + {m.common_remove()} + + {/if} diff --git a/frontend/src/routes/(app)/customize/templates/+page.svelte b/frontend/src/routes/(app)/customize/templates/+page.svelte index 150e34b718..e7128862ed 100644 --- a/frontend/src/routes/(app)/customize/templates/+page.svelte +++ b/frontend/src/routes/(app)/customize/templates/+page.svelte @@ -13,6 +13,7 @@ import { untrack } from 'svelte'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; import { RegistryIcon, TemplateIcon, FolderOpenIcon } from '$lib/icons'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); @@ -129,26 +130,35 @@ } } - const actionButtons: ActionButton[] = [ - { - id: 'create', - action: 'create', - label: m.templates_create_template(), - onclick: () => goto('/customize/templates/create') - }, - { - id: 'default', - action: 'edit', - label: m.templates_edit_default(), - onclick: () => goto('/customize/templates/default') - }, - { + const canCreateTemplate = $derived(hasPermission('templates:create')); + const canUpdateTemplate = $derived(hasPermission('templates:update')); + + const actionButtons: ActionButton[] = $derived.by(() => { + const buttons: ActionButton[] = []; + if (canCreateTemplate) { + buttons.push({ + id: 'create', + action: 'create', + label: m.templates_create_template(), + onclick: () => goto('/customize/templates/create') + }); + } + if (canUpdateTemplate) { + buttons.push({ + id: 'default', + action: 'edit', + label: m.templates_edit_default(), + onclick: () => goto('/customize/templates/default') + }); + } + buttons.push({ id: 'refresh', action: 'restart', label: m.common_refresh(), onclick: refreshTemplates - } - ]; + }); + return buttons; + }); const localTemplatesCount = $derived(templates.data?.filter((t) => !t.isRemote).length ?? 0); const remoteTemplatesCount = $derived(templates.data?.filter((t) => t.isRemote).length ?? 0); diff --git a/frontend/src/routes/(app)/customize/templates/template-table.svelte b/frontend/src/routes/(app)/customize/templates/template-table.svelte index 73a00f76ff..b4f01859e1 100644 --- a/frontend/src/routes/(app)/customize/templates/template-table.svelte +++ b/frontend/src/routes/(app)/customize/templates/template-table.svelte @@ -17,6 +17,8 @@ import { templateTypeFilters } from '$lib/components/arcane-table/data'; import { templateService } from '$lib/services/template-service'; import { truncateString } from '$lib/utils/string.utils'; + import { hasPermission } from '$lib/utils/permissions.util'; + import IfPermitted from '$lib/components/if-permitted.svelte'; import { PersistedState } from 'runed'; import { onMount } from 'svelte'; import IconImage from '$lib/components/icon-image.svelte'; @@ -45,6 +47,9 @@ let deletingId = $state(null); let downloadingId = $state(null); + const canReadTemplate = $derived(hasPermission('templates:read')); + const canDeleteTemplate = $derived(hasPermission('templates:delete')); + async function handleDeleteTemplate(id: string, name: string) { openConfirmDialog({ title: m.common_delete_title({ resource: m.resource_template() }), @@ -283,14 +288,18 @@ {m.common_view_details()} - goto(`/projects/new?templateId=${item.id}`)}> - - {m.compose_create_project()} - + + goto(`/projects/new?templateId=${item.id}`)}> + + {m.compose_create_project()} + + - + {#if (item.isRemote && canReadTemplate) || (!item.isRemote && canDeleteTemplate)} + + {/if} - {#if item.isRemote} + {#if item.isRemote && canReadTemplate} handleDownloadTemplate(item.id, item.name)} disabled={downloadingId === item.id}> {#if downloadingId === item.id} @@ -299,7 +308,7 @@ {/if} {m.templates_download()} - {:else} + {:else if !item.isRemote && canDeleteTemplate} handleDeleteTemplate(item.id, item.name)} diff --git a/frontend/src/routes/(app)/customize/variables/+page.svelte b/frontend/src/routes/(app)/customize/variables/+page.svelte index 9740bd908d..b55d0c9caa 100644 --- a/frontend/src/routes/(app)/customize/variables/+page.svelte +++ b/frontend/src/routes/(app)/customize/variables/+page.svelte @@ -11,6 +11,7 @@ import { untrack } from 'svelte'; import { m } from '$lib/paraglide/messages'; import { SearchIcon, CloseIcon, AlertIcon, VariableIcon } from '$lib/icons'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); let envVars = $state(untrack(() => [...data.variables])); @@ -104,24 +105,30 @@ } }); - const actionButtons = $derived([ - { - id: 'reset', - action: 'restart', - label: m.common_reset(), - onclick: resetForm, - disabled: !hasChanges || isLoading - }, - { - id: 'save', - action: 'save', - label: m.common_save(), - loadingLabel: m.common_saving(), - loading: isLoading, - disabled: isLoading || !hasChanges, - onclick: onSubmit - } - ]); + const canUpdateVariables = $derived(hasPermission('templates:update')); + + const actionButtons = $derived( + canUpdateVariables + ? [ + { + id: 'reset', + action: 'restart', + label: m.common_reset(), + onclick: resetForm, + disabled: !hasChanges || isLoading + }, + { + id: 'save', + action: 'save', + label: m.common_save(), + loadingLabel: m.common_saving(), + loading: isLoading, + disabled: isLoading || !hasChanges, + onclick: onSubmit + } + ] + : [] + ); @@ -168,14 +175,16 @@
- + {#if canUpdateVariables} + + {/if}
@@ -234,17 +243,19 @@ /> - removeEnvVar(actualIndex)} - disabled={isLoading} - class="text-destructive hover:text-destructive" - title={m.common_remove()} - > - - + {#if canUpdateVariables} + removeEnvVar(actualIndex)} + disabled={isLoading} + class="text-destructive hover:text-destructive" + title={m.common_remove()} + > + + + {/if}
diff --git a/frontend/src/routes/(app)/dashboard/+page.svelte b/frontend/src/routes/(app)/dashboard/+page.svelte index 4c46260510..30301239de 100644 --- a/frontend/src/routes/(app)/dashboard/+page.svelte +++ b/frontend/src/routes/(app)/dashboard/+page.svelte @@ -450,7 +450,6 @@ >({}); let upgradeDialogUpgradingById = $state>({}); - const storeUser = fromStore(userStore); const availableEnvironments = $derived(environmentStore.available); const currentEnvironmentId = $derived(environmentStore.selected?.id ?? null); - const currentUserIsAdmin = $derived(!!storeUser.current?.roles?.includes('admin')); + + function canPruneInEnvironment(envId: string): boolean { + return hasAnyPermission(['images:prune', 'volumes:prune', 'networks:prune'], envId); + } + function canUpgradeEnvironment(): boolean { + return hasPermission('environments:update'); + } function shouldLoadEnvironment(environment: Environment): boolean { return environment.enabled && isEnvironmentOnline(environment); @@ -480,14 +484,17 @@ function canPruneEnvironment(item: DashboardEnvironmentOverview): boolean { return ( - currentUserIsAdmin && item.environment.enabled && item.snapshotState === 'ready' && isEnvironmentOnline(item.environment) + canPruneInEnvironment(item.environment.id) && + item.environment.enabled && + item.snapshotState === 'ready' && + isEnvironmentOnline(item.environment) ); } function getEnvironmentActionButtons(item: DashboardEnvironmentOverview, isCurrent: boolean): ActionButton[] { const buttons: ActionButton[] = []; - if (currentUserIsAdmin) { + if (canPruneInEnvironment(item.environment.id)) { buttons.push({ id: `${item.environment.id}-prune`, action: 'prune', @@ -778,7 +785,7 @@ void | Promise; render?: 'both' | 'trigger' | 'dialog'; @@ -32,7 +32,7 @@ upgrading?: boolean; } = $props(); - const shouldCheckUpgrade = $derived(!!(versionInfo.updateAvailable && isAdmin && !debug)); + const shouldCheckUpgrade = $derived(!!(versionInfo.updateAvailable && canUpgradePermission && !debug)); const isLocalEnvironment = $derived(environment.id === '0'); const upgradeAvailabilityQuery = createQuery(() => ({ @@ -45,7 +45,7 @@ staleTime: 0 })); - const canUpgrade = $derived.by(() => { + const upgradeIsAvailable = $derived.by(() => { if (debug) return true; const result = upgradeAvailabilityQuery.data; return !!result?.canUpgrade && !result?.error; @@ -74,7 +74,9 @@ return ''; }); - const shouldShowUpgrade = $derived((versionInfo.updateAvailable && isAdmin && canUpgrade) || (debug && isAdmin)); + const shouldShowUpgrade = $derived( + (versionInfo.updateAvailable && canUpgradePermission && upgradeIsAvailable) || (debug && canUpgradePermission) + ); const upgradeButtonText = $derived.by(() => { if (upgrading) return m.upgrade_in_progress(); diff --git a/frontend/src/routes/(app)/environments/+page.svelte b/frontend/src/routes/(app)/environments/+page.svelte index 5f9fa5e765..5b08af9e14 100644 --- a/frontend/src/routes/(app)/environments/+page.svelte +++ b/frontend/src/routes/(app)/environments/+page.svelte @@ -11,6 +11,7 @@ import { ResourcePageLayout, type ActionButton } from '$lib/layouts/index.js'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { simpleRefresh } from '$lib/utils/refresh.util'; + import { hasPermission } from '$lib/utils/permissions.util'; import { DownloadIcon } from '$lib/icons'; let { data } = $props(); @@ -30,7 +31,8 @@ ); } - const currentUserIsAdmin = $derived(!!data.user?.roles?.includes('admin')); + const canCreateEnvironment = $derived(hasPermission('environments:create')); + const canDeleteEnvironment = $derived(hasPermission('environments:delete')); async function handleBulkDelete() { if (selectedIds.length === 0) return; @@ -82,7 +84,7 @@ } const actionButtons: ActionButton[] = $derived([ - ...(selectedIds.length > 0 && currentUserIsAdmin + ...(selectedIds.length > 0 && canDeleteEnvironment ? [ { id: 'remove-selected', @@ -94,7 +96,7 @@ } ] : []), - ...(currentUserIsAdmin + ...(canCreateEnvironment ? [ { id: 'create', @@ -104,7 +106,7 @@ } ] : []), - ...(currentUserIsAdmin && data.settings?.edgeMTLSManagerCAAvailable + ...(canCreateEnvironment && data.settings?.edgeMTLSManagerCAAvailable ? [ { id: 'download-edge-ca', diff --git a/frontend/src/routes/(app)/environments/environment-table.svelte b/frontend/src/routes/(app)/environments/environment-table.svelte index 1081114604..dbada72605 100644 --- a/frontend/src/routes/(app)/environments/environment-table.svelte +++ b/frontend/src/routes/(app)/environments/environment-table.svelte @@ -21,7 +21,7 @@ import UpdateCenterDialog from '$lib/components/dialogs/update-center-dialog.svelte'; import EnvironmentUpgradeMenuItem from './environment-upgrade-menu-item.svelte'; import type { AppVersionInformation } from '$lib/types/application-configuration'; - import userStore from '$lib/stores/user-store'; + import { hasPermission } from '$lib/utils/permissions.util'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { capitalizeFirstLetter } from '$lib/utils/string.utils'; import { getEnvironmentStatusVariant, isEnvironmentOnline, resolveEnvironmentStatus } from '$lib/utils/environment-status'; @@ -43,12 +43,7 @@ let selectedEnvironmentForUpgrade = $state(null); let selectedVersionInfoForUpgrade = $state(null); - let currentUser = $state<{ roles?: string[] } | null>(null); - $effect(() => { - const unsub = userStore.subscribe((u) => (currentUser = u)); - return unsub; - }); - const isAdmin = $derived(!!currentUser?.roles?.includes('admin')); + const canInstallUpdates = $derived(hasPermission('environments:update')); const environmentTypeFilters: FilterOption[] = [ { value: 'http', label: m.environments_edge_http_label(), icon: EnvironmentsIcon }, @@ -480,7 +475,7 @@ bind:open={showUpgradeDialog} onConfirm={handleConfirmUpgrade} versionInformation={selectedVersionInfoForUpgrade ?? undefined} - canInstall={isAdmin} + canInstall={canInstallUpdates} environmentName={selectedEnvironmentForUpgrade?.name} environmentId={selectedEnvironmentForUpgrade?.id} bind:upgrading={isLoading.upgrading} diff --git a/frontend/src/routes/(app)/events/+page.svelte b/frontend/src/routes/(app)/events/+page.svelte index e0ffcda9a7..f2c411bf33 100644 --- a/frontend/src/routes/(app)/events/+page.svelte +++ b/frontend/src/routes/(app)/events/+page.svelte @@ -10,6 +10,7 @@ import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; import { createMutation, createQuery } from '@tanstack/svelte-query'; import { EventsIcon } from '$lib/icons'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); @@ -85,8 +86,10 @@ }); } + const canManageEvents = $derived(hasPermission('events:read')); + const actionButtons: ActionButton[] = $derived([ - ...(selectedIds.length > 0 + ...(selectedIds.length > 0 && canManageEvents ? [ { id: 'remove-selected', diff --git a/frontend/src/routes/(app)/events/event-table.svelte b/frontend/src/routes/(app)/events/event-table.svelte index c7adf58f4a..7ef37bd313 100644 --- a/frontend/src/routes/(app)/events/event-table.svelte +++ b/frontend/src/routes/(app)/events/event-table.svelte @@ -17,6 +17,7 @@ import { m } from '$lib/paraglide/messages'; import { eventService } from '$lib/services/event-service'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import IfPermitted from '$lib/components/if-permitted.svelte'; import { TrashIcon, InfoIcon, NotificationsIcon, TagIcon, EnvironmentsIcon, UserIcon, EllipsisIcon } from '$lib/icons'; let { @@ -268,16 +269,18 @@ {m.common_view_details()} - + + - handleDeleteEvent(item.id, item.title)} - disabled={isLoading.removing} - > - - {m.common_delete()} - + handleDeleteEvent(item.id, item.title)} + disabled={isLoading.removing} + > + + {m.common_delete()} + + diff --git a/frontend/src/routes/(app)/images/+page.svelte b/frontend/src/routes/(app)/images/+page.svelte index 59fd40bd93..d9cb3d95f8 100644 --- a/frontend/src/routes/(app)/images/+page.svelte +++ b/frontend/src/routes/(app)/images/+page.svelte @@ -10,6 +10,7 @@ import { m } from '$lib/paraglide/messages'; import { imageService } from '$lib/services/image-service'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import { queryKeys } from '$lib/query/query-keys'; import type { ImageUsageCounts } from '$lib/types/image.type'; import { untrack } from 'svelte'; @@ -148,36 +149,55 @@ await Promise.all([imagesQuery.refetch(), imageUsageCountsQuery.refetch()]); } - const actionButtons: ActionButton[] = $derived([ - { id: 'pull', action: 'pull', label: m.images_pull_image(), onclick: () => (isPullDialogOpen = true) }, - { id: 'upload', action: 'create', label: m.images_upload_image(), onclick: () => (isUploadDialogOpen = true) }, - { - id: 'check-updates', - action: 'inspect', - label: m.images_check_updates(), - loadingLabel: m.common_action_checking(), - onclick: handleTriggerBulkUpdateCheck, - loading: isChecking, - disabled: isChecking - }, - { + const canPullImage = $derived(hasPermission('images:pull', envId)); + const canUploadImage = $derived(hasPermission('images:upload', envId)); + const canPruneImage = $derived(hasPermission('images:prune', envId)); + + const actionButtons: ActionButton[] = $derived.by(() => { + const buttons: ActionButton[] = []; + if (canPullImage) { + buttons.push({ id: 'pull', action: 'pull', label: m.images_pull_image(), onclick: () => (isPullDialogOpen = true) }); + } + if (canUploadImage) { + buttons.push({ + id: 'upload', + action: 'create', + label: m.images_upload_image(), + onclick: () => (isUploadDialogOpen = true) + }); + } + if (canPullImage) { + buttons.push({ + id: 'check-updates', + action: 'inspect', + label: m.images_check_updates(), + loadingLabel: m.common_action_checking(), + onclick: handleTriggerBulkUpdateCheck, + loading: isChecking, + disabled: isChecking + }); + } + buttons.push({ id: 'refresh', action: 'restart', label: m.common_refresh(), onclick: refresh, loading: isRefreshing, disabled: isRefreshing - }, - { - id: 'prune', - action: 'remove', - label: m.images_prune_unused(), - loadingLabel: m.common_action_pruning(), - onclick: () => (isConfirmPruneDialogOpen = true), - loading: isPruning, - disabled: isPruning + }); + if (canPruneImage) { + buttons.push({ + id: 'prune', + action: 'remove', + label: m.images_prune_unused(), + loadingLabel: m.common_action_pruning(), + onclick: () => (isConfirmPruneDialogOpen = true), + loading: isPruning, + disabled: isPruning + }); } - ]); + return buttons; + }); const statCards: StatCardConfig[] = $derived([ { diff --git a/frontend/src/routes/(app)/images/[imageId]/+page.svelte b/frontend/src/routes/(app)/images/[imageId]/+page.svelte index 200000097d..a8d423b36e 100644 --- a/frontend/src/routes/(app)/images/[imageId]/+page.svelte +++ b/frontend/src/routes/(app)/images/[imageId]/+page.svelte @@ -21,11 +21,17 @@ import { ResourceDetailLayout, type DetailAction } from '$lib/layouts'; import VulnerabilityScanPanel from '$lib/components/vulnerability/vulnerability-scan-panel.svelte'; import type { VulnerabilityScanResult } from '$lib/types/vulnerability.type'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import { VolumesIcon, ClockIcon, TagIcon, LayersIcon, CpuIcon, InfoIcon, SettingsIcon, HashIcon } from '$lib/icons'; let { data } = $props(); let { image } = $derived(data); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canDeleteImage = $derived(hasPermission('images:delete', currentEnvId)); + const canScanImage = $derived(hasPermission('vulnerabilities:scan', currentEnvId)); + let isLoading = $state({ pulling: false, removing: false, @@ -225,24 +231,30 @@ }); } - const actions: DetailAction[] = $derived([ - { - id: 'scan', - action: 'base', - label: m.vuln_scan(), - loading: isLoading.scanning, - disabled: isLoading.scanning, - onclick: handleScanImage - }, - { - id: 'remove', - action: 'remove', - label: m.common_remove(), - loading: isLoading.removing, - disabled: isLoading.removing, - onclick: () => handleImageRemove(image.id) + const actions: DetailAction[] = $derived.by(() => { + const list: DetailAction[] = []; + if (canScanImage) { + list.push({ + id: 'scan', + action: 'base', + label: m.vuln_scan(), + loading: isLoading.scanning, + disabled: isLoading.scanning, + onclick: handleScanImage + }); + } + if (canDeleteImage) { + list.push({ + id: 'remove', + action: 'remove', + label: m.common_remove(), + loading: isLoading.removing, + disabled: isLoading.removing, + onclick: () => handleImageRemove(image.id) + }); } - ]); + return list; + }); {formatTimestamp(buildHistorySelected.createdAt)}
- buildHistorySelected && applyBuildConfig(buildHistorySelected)} - /> + + buildHistorySelected && applyBuildConfig(buildHistorySelected)} + /> + {#if buildHistorySelected.errorMessage}
diff --git a/frontend/src/routes/(app)/images/builds/build-workspace-browser.svelte b/frontend/src/routes/(app)/images/builds/build-workspace-browser.svelte index 3110b2f5ad..dce1d3f159 100644 --- a/frontend/src/routes/(app)/images/builds/build-workspace-browser.svelte +++ b/frontend/src/routes/(app)/images/builds/build-workspace-browser.svelte @@ -14,6 +14,7 @@ import { toast } from 'svelte-sonner'; import { UseClipboard } from '$lib/hooks/use-clipboard.svelte'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import { queryKeys } from '$lib/query/query-keys'; import type { FileEntry } from '$lib/types/file-browser.type'; import type { FileProvider } from '$lib/components/file-browser'; @@ -35,6 +36,7 @@ let currentPath = $state('/'); const envId = $derived(environmentStore.selected?.id || '0'); + const canBuildImage = $derived(hasPermission('images:build', envId)); const queryClient = useQueryClient(); const filesQuery = createQuery(() => ({ @@ -218,15 +220,17 @@ Copy current path - - (showCreateFolder = true)}> - - {m.volumes_browser_new_folder()} - - (showUpload = true)}> - - {m.volumes_browser_upload_files()} - + {#if canBuildImage} + + (showCreateFolder = true)}> + + {m.volumes_browser_new_folder()} + + (showUpload = true)}> + + {m.volumes_browser_upload_files()} + + {/if}
@@ -249,10 +253,12 @@ minimal onNavigate={handleNavigate} onRefresh={() => filesQuery.refetch()} - onDelete={async (file) => { - await deleteMutation.mutateAsync(file.path); - await filesQuery.refetch(); - }} + onDelete={canBuildImage + ? async (file) => { + await deleteMutation.mutateAsync(file.path); + await filesQuery.refetch(); + } + : undefined} onDownload={async (file) => { await downloadMutation.mutateAsync(file.path); }} @@ -306,13 +312,15 @@ (editorOpen = false)} /> - + {#if canBuildImage} + + {/if} diff --git a/frontend/src/routes/(app)/images/builds/components/build-controls.svelte b/frontend/src/routes/(app)/images/builds/components/build-controls.svelte index 2debefcba8..c5d7857e8b 100644 --- a/frontend/src/routes/(app)/images/builds/components/build-controls.svelte +++ b/frontend/src/routes/(app)/images/builds/components/build-controls.svelte @@ -4,6 +4,7 @@ import * as Select from '$lib/components/ui/select/index.js'; import { Switch } from '$lib/components/ui/switch/index.js'; import { m } from '$lib/paraglide/messages'; + import IfPermitted from '$lib/components/if-permitted.svelte'; import type { BuildFormInputsStore, BuildProviderOption } from './build-form.types'; let { @@ -57,14 +58,16 @@ - onBuild?.()} - loading={isBuilding} - disabled={isBuilding} - /> + + onBuild?.()} + loading={isBuilding} + disabled={isBuilding} + /> + diff --git a/frontend/src/routes/(app)/images/image-table.svelte b/frontend/src/routes/(app)/images/image-table.svelte index 9f41f82735..fd5da3da12 100644 --- a/frontend/src/routes/(app)/images/image-table.svelte +++ b/frontend/src/routes/(app)/images/image-table.svelte @@ -25,6 +25,8 @@ import { imageService } from '$lib/services/image-service'; import { vulnerabilityService } from '$lib/services/vulnerability-service'; import { isLikelyStaleFailedSummary, isVulnerabilityScanInProgress } from '$lib/utils/vulnerability-scan.util'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import { DownloadIcon, @@ -58,6 +60,11 @@ checking: false }); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canDeleteImage = $derived(hasPermission('images:delete', currentEnvId)); + const canPullImage = $derived(hasPermission('images:pull', currentEnvId)); + const canScanImage = $derived(hasPermission('vulnerabilities:scan', currentEnvId)); + let isPullingInline = $state>({}); let isScanningInline = $state>({}); let scanRequestedAtByImage = $state>({}); @@ -413,7 +420,7 @@ action: 'remove', onClick: handleDeleteSelected, loading: isLoading.removing, - disabled: isLoading.removing, + disabled: !canDeleteImage || isLoading.removing, icon: TrashIcon } ]); @@ -689,39 +696,47 @@ {m.common_inspect()} - - - handleInlineImagePull(item.id, item.repoTags?.[0] || '')} - disabled={isPullingInline[item.id] || !item.repoTags?.[0]} - > - {#if isPullingInline[item.id]} - - {:else} - - {/if} - {m.images_pull()} - + {#if canPullImage || canScanImage} + + {/if} - handleInlineVulnerabilityScan(item.id)} disabled={isScanningInline[item.id]}> - {#if isScanningInline[item.id]} - - {:else} - - {/if} - {m.vuln_scan()} - + {#if canPullImage} + handleInlineImagePull(item.id, item.repoTags?.[0] || '')} + disabled={isPullingInline[item.id] || !item.repoTags?.[0]} + > + {#if isPullingInline[item.id]} + + {:else} + + {/if} + {m.images_pull()} + + {/if} - + {#if canScanImage} + handleInlineVulnerabilityScan(item.id)} disabled={isScanningInline[item.id]}> + {#if isScanningInline[item.id]} + + {:else} + + {/if} + {m.vuln_scan()} + + {/if} - deleteImage(item.id)} disabled={isLoading.removing}> - {#if isLoading.removing} - - {:else} - - {/if} - {m.common_remove()} - + {#if canDeleteImage} + + + deleteImage(item.id)} disabled={isLoading.removing}> + {#if isLoading.removing} + + {:else} + + {/if} + {m.common_remove()} + + {/if} diff --git a/frontend/src/routes/(app)/images/vulnerabilities/+page.svelte b/frontend/src/routes/(app)/images/vulnerabilities/+page.svelte index ea1cb38b72..db6b381f4d 100644 --- a/frontend/src/routes/(app)/images/vulnerabilities/+page.svelte +++ b/frontend/src/routes/(app)/images/vulnerabilities/+page.svelte @@ -17,6 +17,8 @@ import { toast } from 'svelte-sonner'; import { InspectIcon } from '$lib/icons'; import * as Tabs from '$lib/components/ui/tabs/index.js'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; let { data } = $props(); @@ -289,27 +291,34 @@ } } - const actionButtons: ActionButton[] = $derived([ - { - id: 'scan-all', - action: 'base', - label: isLoading.scanningAll - ? `${m.security_scanning()} (${scanProgress.current}/${scanProgress.total})` - : m.security_scan_all(), - onclick: scanAllImages, - loading: isLoading.scanningAll, - disabled: isLoading.scanningAll || isLoading.refreshing, - icon: InspectIcon - }, - { + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canScanVuln = $derived(hasPermission('vulnerabilities:scan', currentEnvId)); + + const actionButtons: ActionButton[] = $derived.by(() => { + const buttons: ActionButton[] = []; + if (canScanVuln) { + buttons.push({ + id: 'scan-all', + action: 'base', + label: isLoading.scanningAll + ? `${m.security_scanning()} (${scanProgress.current}/${scanProgress.total})` + : m.security_scan_all(), + onclick: scanAllImages, + loading: isLoading.scanningAll, + disabled: isLoading.scanningAll || isLoading.refreshing, + icon: InspectIcon + }); + } + buttons.push({ id: 'refresh', action: 'restart', label: m.common_refresh(), onclick: refreshAll, loading: isLoading.refreshing, disabled: isLoading.refreshing || isLoading.scanningAll - } - ]); + }); + return buttons; + }); diff --git a/frontend/src/routes/(app)/images/vulnerabilities/ignored-vulnerabilities-table.svelte b/frontend/src/routes/(app)/images/vulnerabilities/ignored-vulnerabilities-table.svelte index 27e960f32b..a58e791162 100644 --- a/frontend/src/routes/(app)/images/vulnerabilities/ignored-vulnerabilities-table.svelte +++ b/frontend/src/routes/(app)/images/vulnerabilities/ignored-vulnerabilities-table.svelte @@ -4,6 +4,7 @@ import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import { ShieldAlertIcon, CodeIcon, ImagesIcon, EyeOnIcon } from '$lib/icons'; import { ArcaneButton } from '$lib/components/arcane-button'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { ignoredVulnerabilities, @@ -84,14 +85,16 @@ {/if} - onUnignore(item.id)} - disabled={isLoading} - /> + + onUnignore(item.id)} + disabled={isLoading} + /> + {/each} diff --git a/frontend/src/routes/(app)/images/vulnerabilities/security-vulnerability-table.svelte b/frontend/src/routes/(app)/images/vulnerabilities/security-vulnerability-table.svelte index 68d883c9b5..0bed6f7304 100644 --- a/frontend/src/routes/(app)/images/vulnerabilities/security-vulnerability-table.svelte +++ b/frontend/src/routes/(app)/images/vulnerabilities/security-vulnerability-table.svelte @@ -10,6 +10,8 @@ import { ShieldAlertIcon, CodeIcon, CheckIcon, ImagesIcon, EyeOffIcon } from '$lib/icons'; import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; const DEFAULT_PAGE_SIZE = 20; @@ -25,6 +27,9 @@ let imageNameFilterOptions = $state([]); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canManageVuln = $derived(hasPermission('vulnerabilities:manage', currentEnvId)); + function getSeverityLabel(severity: string): string { switch (severity) { case 'CRITICAL': @@ -295,14 +300,16 @@ {/snippet} {#snippet rowActions({ item }: { item: VulnerabilityRow })} - + {#if canManageVuln} + + {/if} {/snippet} diff --git a/frontend/src/routes/(app)/no-access/+page.svelte b/frontend/src/routes/(app)/no-access/+page.svelte new file mode 100644 index 0000000000..f6fe2c5ecb --- /dev/null +++ b/frontend/src/routes/(app)/no-access/+page.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/routes/(app)/projects/+page.svelte b/frontend/src/routes/(app)/projects/+page.svelte index 5595394ade..c1b5582a0e 100644 --- a/frontend/src/routes/(app)/projects/+page.svelte +++ b/frontend/src/routes/(app)/projects/+page.svelte @@ -8,6 +8,7 @@ import { projectService } from '$lib/services/project-service'; import { imageService } from '$lib/services/image-service'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import { queryKeys } from '$lib/query/query-keys'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { ProjectStatusCounts } from '$lib/types/project.type'; @@ -150,30 +151,39 @@ await goto(`${url.pathname}${url.search}`, { keepFocus: true, replaceState: true, noScroll: true }); } - const actionButtons: ActionButton[] = $derived([ - { - id: 'create', - action: 'create', - label: m.compose_create_project(), - onclick: () => goto('/projects/new') - }, - { - id: 'check-updates', - action: 'update', - label: m.compose_update_projects(), - onclick: handleCheckForUpdates, - loading: checkUpdatesMutation.isPending, - disabled: checkUpdatesMutation.isPending - }, - { + const canCreateProject = $derived(hasPermission('projects:create', envId)); + const canDeployProject = $derived(hasPermission('projects:deploy', envId)); + + const actionButtons: ActionButton[] = $derived.by(() => { + const buttons: ActionButton[] = []; + if (canCreateProject) { + buttons.push({ + id: 'create', + action: 'create', + label: m.compose_create_project(), + onclick: () => goto('/projects/new') + }); + } + if (canDeployProject) { + buttons.push({ + id: 'check-updates', + action: 'update', + label: m.compose_update_projects(), + onclick: handleCheckForUpdates, + loading: checkUpdatesMutation.isPending, + disabled: checkUpdatesMutation.isPending + }); + } + buttons.push({ id: 'refresh', action: 'restart', label: m.common_refresh(), onclick: refreshCompose, loading: isManualRefreshing, disabled: isRefreshBlocked - } - ]); + }); + return buttons; + }); const statCards: StatCardConfig[] = $derived([ { diff --git a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte index 6150a3fbae..5f1a4ea953 100644 --- a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte +++ b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte @@ -34,11 +34,13 @@ import { imageService } from '$lib/services/image-service'; import { gitOpsSyncService } from '$lib/services/gitops-sync-service'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import { queryKeys } from '$lib/query/query-keys'; import { RefreshIcon } from '$lib/icons'; import IconImage from '$lib/components/icon-image.svelte'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import ProjectUpdateItem from '$lib/components/project-update-item.svelte'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { data } = $props(); let projectId = $derived(data.projectId); @@ -59,6 +61,10 @@ }); const envId = $derived(environmentStore.selected?.id || '0'); + const canUpdateProject = $derived(hasPermission('projects:update', envId)); + const canViewProjectLogs = $derived(hasPermission('projects:logs', envId)); + // Project lifecycle permissions are evaluated per-button inside + // directly; no need to derive them here. let includeFilesState = $state>({}); let loadedIncludeFileContents = $state>({}); @@ -139,14 +145,15 @@ let isGitOpsManaged = $derived(!!project?.gitOpsManagedBy); let hasBuildDirective = $derived(!!project?.hasBuildDirective); let canEditName = $derived( - !project?.isArchived && + canUpdateProject && + !project?.isArchived && !isGitOpsManaged && !isLoading.saving && project?.status !== 'running' && project?.status !== 'partially running' ); - let canEditCompose = $derived(!project?.isArchived && !isGitOpsManaged); - let canEditEnv = $derived(!project?.isArchived); + let canEditCompose = $derived(canUpdateProject && !project?.isArchived && !isGitOpsManaged); + let canEditEnv = $derived(canUpdateProject && !project?.isArchived); let composeFileName = $derived(project?.composeFileName || 'compose.yaml'); let archiveRequiresStopped = $derived( !!project && @@ -208,7 +215,7 @@ ) ); - let canSave = $derived(!project?.isArchived && hasChanges && !hasAnyErrors); + let canSave = $derived(canUpdateProject && !project?.isArchived && hasChanges && !hasAnyErrors); const tabItems = $derived([ { @@ -222,12 +229,16 @@ label: m.common_configuration(), icon: SettingsIcon }, - { - value: 'logs', - label: m.compose_nav_logs(), - icon: FileTextIcon, - disabled: project?.status !== 'running' - } + ...(canViewProjectLogs + ? [ + { + value: 'logs', + label: m.compose_nav_logs(), + icon: FileTextIcon, + disabled: project?.status !== 'running' + } + ] + : []) ]); let nameInputRef = $state(null); @@ -744,7 +755,7 @@ {#snippet headerActions()}
- {#if hasChanges} + {#if hasChanges && canUpdateProject} {/if} -
- + {#if canUpdateProject} + + {/if} {/if} diff --git a/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte b/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte index f29e481ca7..6cf355e395 100644 --- a/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte +++ b/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte @@ -18,6 +18,8 @@ import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; import { tryCatch } from '$lib/utils/try-catch'; import { containerService } from '$lib/services/container-service'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; import * as ArcaneTooltip from '$lib/components/arcane-tooltip'; import IconImage from '$lib/components/icon-image.svelte'; import { getArcaneIconUrlFromLabels } from '$lib/utils/arcane-labels'; @@ -41,6 +43,12 @@ let { services = [], projectId, onRefresh }: Props = $props(); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canStartContainer = $derived(hasPermission('containers:start', currentEnvId)); + const canStopContainer = $derived(hasPermission('containers:stop', currentEnvId)); + const canRestartContainer = $derived(hasPermission('containers:restart', currentEnvId)); + const canDeleteContainer = $derived(hasPermission('containers:delete', currentEnvId)); + // Convert RuntimeService to a format compatible with ArcaneTable type ServiceWithId = RuntimeService & { id: string }; @@ -270,7 +278,7 @@ action: 'start', onClick: (ids) => handleBulkAction('start', ids), loading: isBulkLoading.start, - disabled: isAnyLoading, + disabled: !canStartContainer || isAnyLoading, icon: StartIcon }, { @@ -279,7 +287,7 @@ action: 'stop', onClick: (ids) => handleBulkAction('stop', ids), loading: isBulkLoading.stop, - disabled: isAnyLoading, + disabled: !canStopContainer || isAnyLoading, icon: StopIcon }, { @@ -288,7 +296,7 @@ action: 'restart', onClick: (ids) => handleBulkAction('restart', ids), loading: isBulkLoading.restart, - disabled: isAnyLoading, + disabled: !canRestartContainer || isAnyLoading, icon: RefreshIcon }, { @@ -297,7 +305,7 @@ action: 'remove', onClick: (ids) => handleBulkAction('remove', ids), loading: isBulkLoading.remove, - disabled: isAnyLoading, + disabled: !canDeleteContainer || isAnyLoading, icon: TrashIcon } ]); @@ -441,19 +449,21 @@ {m.common_inspect()} - - handleRemoveContainer(item.containerId!, item.containerName || item.name)} - disabled={actionStatus[item.id] === 'removing' || isAnyLoading} - > - {#if actionStatus[item.id] === 'removing'} - - {:else} - - {/if} - {m.common_remove()} - + {#if canDeleteContainer} + + handleRemoveContainer(item.containerId!, item.containerName || item.name)} + disabled={actionStatus[item.id] === 'removing' || isAnyLoading} + > + {#if actionStatus[item.id] === 'removing'} + + {:else} + + {/if} + {m.common_remove()} + + {/if} @@ -499,44 +509,50 @@ - performContainerAction('stop', item.containerId!)} - disabled={status === 'stopping' || isAnyLoading} - > - {#if status === 'stopping'} - - {:else} - - {/if} - {m.common_stop()} - - - performContainerAction('restart', item.containerId!)} - disabled={status === 'restarting' || isAnyLoading} - > - {#if status === 'restarting'} - - {:else} - - {/if} - {m.common_restart()} - - - - - handleRemoveContainer(item.containerId!, item.containerName || item.name)} - disabled={status === 'removing' || isAnyLoading} - > - {#if status === 'removing'} - - {:else} - - {/if} - {m.common_remove()} - + {#if canStopContainer} + performContainerAction('stop', item.containerId!)} + disabled={status === 'stopping' || isAnyLoading} + > + {#if status === 'stopping'} + + {:else} + + {/if} + {m.common_stop()} + + {/if} + + {#if canRestartContainer} + performContainerAction('restart', item.containerId!)} + disabled={status === 'restarting' || isAnyLoading} + > + {#if status === 'restarting'} + + {:else} + + {/if} + {m.common_restart()} + + {/if} + + {#if canDeleteContainer} + + + handleRemoveContainer(item.containerId!, item.containerName || item.name)} + disabled={status === 'removing' || isAnyLoading} + > + {#if status === 'removing'} + + {:else} + + {/if} + {m.common_remove()} + + {/if} diff --git a/frontend/src/routes/(app)/projects/new/+page.svelte b/frontend/src/routes/(app)/projects/new/+page.svelte index 737f0ffd64..5af9ca154e 100644 --- a/frontend/src/routes/(app)/projects/new/+page.svelte +++ b/frontend/src/routes/(app)/projects/new/+page.svelte @@ -25,10 +25,15 @@ import CodePanel from '../components/CodePanel.svelte'; import EditableName from '../components/EditableName.svelte'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; + import IfPermitted from '$lib/components/if-permitted.svelte'; import { ComposeEditorSplit } from '$lib/components/compose'; let { data } = $props(); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canCreateProject = $derived(hasPermission('projects:create', currentEnvId)); + let saving = $state(false); let converting = $state(false); let creatingTemplate = $state(false); @@ -216,7 +221,7 @@ > - {#if !hasEditorErrors} + {#if !hasEditorErrors && canCreateProject} {m.git_from_git_repo()} - - - {#if creatingTemplate} - - {:else} - - {/if} - {m.templates_create_template()} - + + + + {#if creatingTemplate} + + {:else} + + {/if} + {m.templates_create_template()} + + diff --git a/frontend/src/routes/(app)/projects/projects-table.svelte b/frontend/src/routes/(app)/projects/projects-table.svelte index 09c62fd741..1fa718f30e 100644 --- a/frontend/src/routes/(app)/projects/projects-table.svelte +++ b/frontend/src/routes/(app)/projects/projects-table.svelte @@ -19,6 +19,8 @@ import { projectService } from '$lib/services/project-service'; import { FolderOpenIcon, LayersIcon, CalendarIcon, ProjectsIcon, GitBranchIcon, RefreshIcon } from '$lib/icons'; import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/permissions.util'; + import IfPermitted from '$lib/components/if-permitted.svelte'; import IconImage from '$lib/components/icon-image.svelte'; import type { ActionStatus } from './projects-table.helpers'; import { createProjectActions } from './projects-table.actions'; @@ -107,6 +109,11 @@ let mobileFieldVisibility = $state>({}); const envId = $derived(environmentStore.selected?.id); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canUpdateProject = $derived(hasPermission('projects:update', currentEnvId)); + const canDeployProject = $derived(hasPermission('projects:deploy', currentEnvId)); + const canDownProject = $derived(hasPermission('projects:down', currentEnvId)); + const canArchiveProject = $derived(hasPermission('projects:archive', currentEnvId)); const { performProjectAction, @@ -175,7 +182,7 @@ action: 'up', onClick: handleBulkUp, loading: isBulkLoading.up, - disabled: isAnyLoading || hasArchivedSelection, + disabled: !canDeployProject || isAnyLoading || hasArchivedSelection, disabledReason: hasArchivedSelection ? m.projects_archived_badge() : undefined, icon: StartIcon }, @@ -185,7 +192,7 @@ action: 'down', onClick: handleBulkDown, loading: isBulkLoading.down, - disabled: isAnyLoading || hasArchivedSelection, + disabled: !canDownProject || isAnyLoading || hasArchivedSelection, disabledReason: hasArchivedSelection ? m.projects_archived_badge() : undefined, icon: StopIcon }, @@ -195,7 +202,7 @@ action: 'redeploy', onClick: handleBulkRedeploy, loading: isBulkLoading.redeploy, - disabled: isAnyLoading || hasRedeployDisabledSelection || hasArchivedSelection, + disabled: !canDeployProject || isAnyLoading || hasRedeployDisabledSelection || hasArchivedSelection, disabledReason: hasArchivedSelection ? m.projects_archived_badge() : hasRedeployDisabledSelection @@ -209,7 +216,7 @@ action: 'base', onClick: handleBulkArchive, loading: isBulkLoading.archive, - disabled: isAnyLoading || hasArchivedSelection || hasRunningSelection, + disabled: !canArchiveProject || isAnyLoading || hasArchivedSelection || hasRunningSelection, disabledReason: hasRunningSelection ? m.projects_archive_requires_stopped() : hasArchivedSelection @@ -379,7 +386,7 @@ {m.common_edit()} - {#if item.gitOpsManagedBy} + {#if item.gitOpsManagedBy && canUpdateProject} handleSyncFromGit(item.id, item.gitOpsManagedBy!)} disabled={status === 'syncing' || isAnyLoading} @@ -396,107 +403,119 @@ {#if item.status !== 'running'} - performProjectAction('start', item.id)} - disabled={item.isArchived || status === 'starting' || isAnyLoading} - title={item.isArchived ? m.projects_archived_badge() : undefined} - > - {#if status === 'starting'} - - {:else} - - {/if} - {m.common_up()} - + {#if canDeployProject} + performProjectAction('start', item.id)} + disabled={item.isArchived || status === 'starting' || isAnyLoading} + title={item.isArchived ? m.projects_archived_badge() : undefined} + > + {#if status === 'starting'} + + {:else} + + {/if} + {m.common_up()} + + {/if} {:else} - performProjectAction('stop', item.id)} - disabled={item.isArchived || status === 'stopping' || isAnyLoading} - title={item.isArchived ? m.projects_archived_badge() : undefined} - > - {#if status === 'stopping'} - - {:else} - - {/if} - {m.common_down()} - + {#if canDownProject} + performProjectAction('stop', item.id)} + disabled={item.isArchived || status === 'stopping' || isAnyLoading} + title={item.isArchived ? m.projects_archived_badge() : undefined} + > + {#if status === 'stopping'} + + {:else} + + {/if} + {m.common_down()} + + {/if} - performProjectAction('restart', item.id)} - disabled={item.isArchived || status === 'restarting' || isAnyLoading} - title={item.isArchived ? m.projects_archived_badge() : undefined} - > - {#if status === 'restarting'} - - {:else} - - {/if} - {m.common_restart()} - + + performProjectAction('restart', item.id)} + disabled={item.isArchived || status === 'restarting' || isAnyLoading} + title={item.isArchived ? m.projects_archived_badge() : undefined} + > + {#if status === 'restarting'} + + {:else} + + {/if} + {m.common_restart()} + + {/if} - {#if item.redeployDisabled} - - - {m.compose_pull_redeploy()} - - {:else} - performProjectAction('redeploy', item.id)} - disabled={item.isArchived || status === 'redeploying' || isAnyLoading} - title={item.isArchived ? m.projects_archived_badge() : undefined} - > - {#if status === 'redeploying'} - - {:else} - - {/if} - {m.compose_pull_redeploy()} - + {#if canDeployProject} + {#if item.redeployDisabled} + + + {m.compose_pull_redeploy()} + + {:else} + performProjectAction('redeploy', item.id)} + disabled={item.isArchived || status === 'redeploying' || isAnyLoading} + title={item.isArchived ? m.projects_archived_badge() : undefined} + > + {#if status === 'redeploying'} + + {:else} + + {/if} + {m.compose_pull_redeploy()} + + {/if} {/if} - {#if item.isArchived} - performProjectAction('unarchive', item.id)} - disabled={status === 'unarchiving' || isAnyLoading} - > - {#if status === 'unarchiving'} - - {:else} - - {/if} - {m.projects_unarchive()} - - {:else} + {#if canArchiveProject} + {#if item.isArchived} + performProjectAction('unarchive', item.id)} + disabled={status === 'unarchiving' || isAnyLoading} + > + {#if status === 'unarchiving'} + + {:else} + + {/if} + {m.projects_unarchive()} + + {:else} + performProjectAction('archive', item.id)} + disabled={isProjectArchiveBlocked(item) || status === 'archiving' || isAnyLoading} + title={isProjectArchiveBlocked(item) ? m.projects_archive_requires_stopped() : undefined} + > + {#if status === 'archiving'} + + {:else} + + {/if} + {m.projects_archive()} + + {/if} + {/if} + + performProjectAction('archive', item.id)} - disabled={isProjectArchiveBlocked(item) || status === 'archiving' || isAnyLoading} - title={isProjectArchiveBlocked(item) ? m.projects_archive_requires_stopped() : undefined} + variant="destructive" + onclick={() => handleDestroyProject(item.id)} + disabled={status === 'destroying' || isAnyLoading} > - {#if status === 'archiving'} + {#if status === 'destroying'} {:else} - + {/if} - {m.projects_archive()} + {m.compose_destroy()} - {/if} - - handleDestroyProject(item.id)} - disabled={status === 'destroying' || isAnyLoading} - > - {#if status === 'destroying'} - - {:else} - - {/if} - {m.compose_destroy()} - + diff --git a/frontend/src/routes/(app)/settings/api-keys/+page.svelte b/frontend/src/routes/(app)/settings/api-keys/+page.svelte index 49884107d2..72bdc50737 100644 --- a/frontend/src/routes/(app)/settings/api-keys/+page.svelte +++ b/frontend/src/routes/(app)/settings/api-keys/+page.svelte @@ -5,7 +5,7 @@ import ApiKeyTable from './api-key-table.svelte'; import ApiKeyFormSheet from '$lib/components/sheets/api-key-form-sheet.svelte'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; - import type { ApiKey, ApiKeyCreated } from '$lib/types/api-key.type'; + import type { ApiKey, ApiKeyCreated, CreateApiKey } from '$lib/types/api-key.type'; import { apiKeyService } from '$lib/services/api-key-service'; import { SettingsPageLayout, type SettingsActionButton } from '$lib/layouts/index.js'; import * as ResponsiveDialog from '$lib/components/ui/responsive-dialog/index.js'; @@ -51,7 +51,7 @@ isEditMode, apiKeyId }: { - apiKey: { name: string; description?: string; expiresAt?: string }; + apiKey: CreateApiKey; isEditMode: boolean; apiKeyId?: string; }) { @@ -129,11 +129,20 @@ - + { } } satisfies SearchPaginationSortRequest); - const apiKeys = await queryClient.fetchQuery({ - queryKey: queryKeys.apiKeys.list(apiKeyRequestOptions), - queryFn: () => apiKeyService.getApiKeys(apiKeyRequestOptions) - }); + const [apiKeys, permissionsManifest] = await Promise.all([ + queryClient.fetchQuery({ + queryKey: queryKeys.apiKeys.list(apiKeyRequestOptions), + queryFn: () => apiKeyService.getApiKeys(apiKeyRequestOptions) + }), + queryClient.fetchQuery({ + queryKey: ['permissions', 'manifest'], + queryFn: () => roleService.getPermissionsManifest(), + staleTime: Infinity + }) + ]); return { apiKeys, - apiKeyRequestOptions + apiKeyRequestOptions, + permissionsManifest }; }; diff --git a/frontend/src/routes/(app)/settings/api-keys/api-key-table.svelte b/frontend/src/routes/(app)/settings/api-keys/api-key-table.svelte index ca7966d5dc..272ba983ca 100644 --- a/frontend/src/routes/(app)/settings/api-keys/api-key-table.svelte +++ b/frontend/src/routes/(app)/settings/api-keys/api-key-table.svelte @@ -15,6 +15,7 @@ import { apiKeyService } from '$lib/services/api-key-service'; import * as m from '$lib/paraglide/messages.js'; import { ApiKeyIcon, TrashIcon, EditIcon, EllipsisIcon } from '$lib/icons'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { apiKeys = $bindable(), @@ -58,11 +59,19 @@ return apiKey.isStatic; } + function isBootstrapApiKey(apiKey: ApiKey): boolean { + return apiKey.isBootstrap; + } + const selectedStaticApiKeyCount = $derived.by( - () => apiKeys.data.filter((apiKey) => selectedIds.includes(apiKey.id) && isStaticApiKey(apiKey)).length + () => + apiKeys.data.filter((apiKey) => selectedIds.includes(apiKey.id) && (isStaticApiKey(apiKey) || isBootstrapApiKey(apiKey))) + .length ); const selectedDeletableApiKeyIds = $derived.by(() => - apiKeys.data.filter((apiKey) => selectedIds.includes(apiKey.id) && !isStaticApiKey(apiKey)).map((apiKey) => apiKey.id) + apiKeys.data + .filter((apiKey) => selectedIds.includes(apiKey.id) && !isStaticApiKey(apiKey) && !isBootstrapApiKey(apiKey)) + .map((apiKey) => apiKey.id) ); async function handleDeleteSelected() { @@ -260,20 +269,24 @@ - onEditApiKey(item)} disabled={isStaticApiKey(item)}> - - {m.common_edit()} - + + onEditApiKey(item)} disabled={isStaticApiKey(item) || isBootstrapApiKey(item)}> + + {m.common_edit()} + + - handleDeleteApiKey(item.id, item.name)} - disabled={isStaticApiKey(item)} - > - - {m.common_delete()} - + + handleDeleteApiKey(item.id, item.name)} + disabled={isStaticApiKey(item) || isBootstrapApiKey(item)} + > + + {m.common_delete()} + + diff --git a/frontend/src/routes/(app)/settings/authentication/+page.svelte b/frontend/src/routes/(app)/settings/authentication/+page.svelte index f918d8744c..9007c1528e 100644 --- a/frontend/src/routes/(app)/settings/authentication/+page.svelte +++ b/frontend/src/routes/(app)/settings/authentication/+page.svelte @@ -18,8 +18,68 @@ import * as Collapsible from '$lib/components/ui/collapsible'; import SettingsRow from '$lib/components/settings/settings-row.svelte'; import { cn } from '$lib/utils'; + import OidcMappingTable from './oidc-mapping-table.svelte'; + import OidcMappingFormSheet from '$lib/components/sheets/oidc-mapping-form-sheet.svelte'; + import type { OidcRoleMapping, CreateOidcRoleMapping, UpdateOidcRoleMapping } from '$lib/types/role.type'; + import { oidcMappingService } from '$lib/services/oidc-mapping-service'; + import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; + import { tryCatch } from '$lib/utils/try-catch'; + import { untrack } from 'svelte'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { data }: PageProps = $props(); + + // OIDC role mappings — co-located with the OIDC settings so admins + // configure the groups claim and the mappings that read it in one place. + let oidcMappings = $state(untrack(() => data.oidcMappings ?? [])); + let mappingSheetOpen = $state(false); + let editingMapping = $state(null); + let mappingSaving = $state(false); + + async function refreshMappings() { + oidcMappings = await oidcMappingService.list(); + } + + function openCreateMapping() { + editingMapping = null; + mappingSheetOpen = true; + } + + function openEditMapping(mapping: OidcRoleMapping) { + editingMapping = mapping; + mappingSheetOpen = true; + } + + async function submitMapping(formData: { claimValue: string; roleId: string; environmentId?: string }) { + mappingSaving = true; + const payload: CreateOidcRoleMapping | UpdateOidcRoleMapping = formData; + + if (editingMapping) { + const id = editingMapping.id; + handleApiResultWithCallbacks({ + result: await tryCatch(oidcMappingService.update(id, payload)), + message: m.common_update_failed({ resource: m.resource_oidc_mapping() }), + setLoadingState: (v) => (mappingSaving = v), + onSuccess: async () => { + toast.success(m.common_update_success({ resource: m.resource_oidc_mapping_cap() })); + await refreshMappings(); + mappingSheetOpen = false; + editingMapping = null; + } + }); + } else { + handleApiResultWithCallbacks({ + result: await tryCatch(oidcMappingService.create(payload)), + message: m.common_create_failed({ resource: m.resource_oidc_mapping() }), + setLoadingState: (v) => (mappingSaving = v), + onSuccess: async () => { + toast.success(m.common_create_success({ resource: m.resource_oidc_mapping_cap() })); + await refreshMappings(); + mappingSheetOpen = false; + } + }); + } + } const currentSettings = $derived($settingsStore || data.settings!); const isReadOnly = $derived.by(() => $settingsStore.uiConfigDisabled); const isAutoLoginEnabled = $derived(settingsStore.autoLoginEnabled.isEnabled()); @@ -41,8 +101,7 @@ oidcClientSecret: z.string(), oidcIssuerUrl: z.string(), oidcScopes: z.string(), - oidcAdminClaim: z.string(), - oidcAdminValue: z.string(), + oidcGroupsClaim: z.string(), oidcProviderName: z.string(), oidcProviderLogoUrl: z.string() }) @@ -73,8 +132,7 @@ oidcClientSecret: '', oidcIssuerUrl: currentSettings.oidcIssuerUrl, oidcScopes: currentSettings.oidcScopes, - oidcAdminClaim: currentSettings.oidcAdminClaim, - oidcAdminValue: currentSettings.oidcAdminValue, + oidcGroupsClaim: currentSettings.oidcGroupsClaim, oidcProviderName: currentSettings.oidcProviderName, oidcProviderLogoUrl: currentSettings.oidcProviderLogoUrl }); @@ -95,8 +153,7 @@ oidcClientSecret: '', oidcIssuerUrl: ($settingsStore || data.settings!).oidcIssuerUrl, oidcScopes: ($settingsStore || data.settings!).oidcScopes, - oidcAdminClaim: ($settingsStore || data.settings!).oidcAdminClaim, - oidcAdminValue: ($settingsStore || data.settings!).oidcAdminValue, + oidcGroupsClaim: ($settingsStore || data.settings!).oidcGroupsClaim, oidcProviderName: ($settingsStore || data.settings!).oidcProviderName, oidcProviderLogoUrl: ($settingsStore || data.settings!).oidcProviderLogoUrl }), @@ -115,8 +172,7 @@ $formInputs.oidcClientId.value !== currentSettings.oidcClientId || $formInputs.oidcIssuerUrl.value !== currentSettings.oidcIssuerUrl || $formInputs.oidcScopes.value !== currentSettings.oidcScopes || - $formInputs.oidcAdminClaim.value !== currentSettings.oidcAdminClaim || - $formInputs.oidcAdminValue.value !== currentSettings.oidcAdminValue || + $formInputs.oidcGroupsClaim.value !== currentSettings.oidcGroupsClaim || $formInputs.oidcProviderName.value !== currentSettings.oidcProviderName || $formInputs.oidcProviderLogoUrl.value !== currentSettings.oidcProviderLogoUrl || $formInputs.oidcClientSecret.value !== '' @@ -159,8 +215,7 @@ oidcClientId: formData.oidcClientId, oidcIssuerUrl: formData.oidcIssuerUrl, oidcScopes: formData.oidcScopes, - oidcAdminClaim: formData.oidcAdminClaim, - oidcAdminValue: formData.oidcAdminValue, + oidcGroupsClaim: formData.oidcGroupsClaim, oidcProviderName: formData.oidcProviderName, oidcProviderLogoUrl: formData.oidcProviderLogoUrl, ...(formData.oidcClientSecret && { oidcClientSecret: formData.oidcClientSecret }) @@ -364,31 +419,15 @@ error={$formInputs.oidcScopes.error} /> -
-
-

{m.oidc_admin_role_mapping_title()}

-

{m.oidc_admin_role_mapping_description()}

-
-
- - -
-
+
+ + +
+
+
+

{m.oidc_role_mappings_title()}

+

{m.oidc_role_mappings_description()}

+
+ +
+ {#if oidcMappings.length > 0} + + {:else} +
+

{m.oidc_mappings_empty_body()}

+
+ {/if} +
+
{/if} @@ -530,5 +600,14 @@ + + {/snippet} diff --git a/frontend/src/routes/(app)/settings/authentication/+page.ts b/frontend/src/routes/(app)/settings/authentication/+page.ts new file mode 100644 index 0000000000..285d4a0389 --- /dev/null +++ b/frontend/src/routes/(app)/settings/authentication/+page.ts @@ -0,0 +1,27 @@ +import { oidcMappingService } from '$lib/services/oidc-mapping-service'; +import { roleService } from '$lib/services/role-service'; +import { environmentManagementService } from '$lib/services/env-mgmt-service'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ parent }) => { + const parentData = await parent(); + + // OIDC mappings live alongside the OIDC config in this page. We load them + // here (instead of a standalone /settings/oidc-mappings route) so admins + // configure groups claim + the mappings that read from it in one place. + const [mappings, roles, environmentsPage] = await Promise.all([ + oidcMappingService.list(), + roleService.getAll(), + environmentManagementService.getEnvironments({ + pagination: { page: 1, limit: 1000 }, + sort: { column: 'name', direction: 'asc' } + }) + ]); + + return { + ...parentData, + oidcMappings: mappings, + roles, + environments: environmentsPage.data + }; +}; diff --git a/frontend/src/routes/(app)/settings/authentication/oidc-mapping-table.svelte b/frontend/src/routes/(app)/settings/authentication/oidc-mapping-table.svelte new file mode 100644 index 0000000000..a8595c5cf2 --- /dev/null +++ b/frontend/src/routes/(app)/settings/authentication/oidc-mapping-table.svelte @@ -0,0 +1,253 @@ + + +{#snippet ClaimCell({ item }: { item: OidcRoleMapping })} +
+ {item.claimValue} + {#if item.source === 'env'} + + {/if} +
+{/snippet} + +{#snippet RoleCell({ item }: { item: OidcRoleMapping })} + {@const role = rolesById[item.roleId]} + +{/snippet} + +{#snippet ScopeCell({ item }: { item: OidcRoleMapping })} + {getEnvName(item.environmentId)} +{/snippet} + +{#snippet OidcMappingMobileCardSnippet({ + item, + mobileFieldVisibility +}: { + item: OidcRoleMapping; + mobileFieldVisibility: MobileFieldVisibility; +})} + {@const role = rolesById[item.roleId]} + item.claimValue} + subtitle={() => null} + badges={[ + (item: OidcRoleMapping) => ({ + variant: getRoleBadgeVariant(rolesById[item.roleId]), + text: getRoleName(item.roleId) + }) + ]} + fields={[ + { + label: m.oidc_mappings_col_scope(), + getValue: (item: OidcRoleMapping) => getEnvName(item.environmentId), + icon: ShieldAlertIcon, + iconVariant: 'gray' as const, + show: mobileFieldVisibility['environmentId'] ?? true + } + ]} + rowActions={RowActions} + /> +{/snippet} + +{#snippet RowActions({ item }: { item: OidcRoleMapping })} + + + {#snippet child({ props })} + + {m.common_open_menu()} + + + {/snippet} + + + + onEdit(item)}> + + {m.common_edit()} + + + + + handleDeleteMapping(item)} + > + + {m.common_delete()} + + + + +{/snippet} + + { + await onRefresh(); + return paginatedMappings; + }} + {columns} + {mobileFields} + rowActions={RowActions} + mobileCard={OidcMappingMobileCardSnippet} +/> diff --git a/frontend/src/routes/(app)/settings/roles/+page.svelte b/frontend/src/routes/(app)/settings/roles/+page.svelte new file mode 100644 index 0000000000..4e8f838826 --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/+page.svelte @@ -0,0 +1,48 @@ + + + + {#snippet mainContent()} + + {/snippet} + diff --git a/frontend/src/routes/(app)/settings/roles/+page.ts b/frontend/src/routes/(app)/settings/roles/+page.ts new file mode 100644 index 0000000000..5cdecb749e --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/+page.ts @@ -0,0 +1,35 @@ +import { roleService } from '$lib/services/role-service'; +import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import { resolveInitialTableRequest } from '$lib/utils/table-persistence.util'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ parent }) => { + const { queryClient } = await parent(); + + const rolesRequestOptions = resolveInitialTableRequest('arcane-roles-table', { + pagination: { + page: 1, + limit: 20 + }, + sort: { + column: 'name', + direction: 'asc' + } + } satisfies SearchPaginationSortRequest); + + const roles = await queryClient.fetchQuery({ + queryKey: ['roles', 'list', rolesRequestOptions], + queryFn: () => roleService.getRoles(rolesRequestOptions) + }); + + const permissionsManifest = await queryClient.fetchQuery({ + queryKey: ['roles', 'permissions-manifest'], + queryFn: () => roleService.getPermissionsManifest() + }); + + return { + roles, + permissionsManifest, + rolesRequestOptions + }; +}; diff --git a/frontend/src/routes/(app)/settings/roles/[id]/+page.svelte b/frontend/src/routes/(app)/settings/roles/[id]/+page.svelte new file mode 100644 index 0000000000..2ef12e35b0 --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/[id]/+page.svelte @@ -0,0 +1,56 @@ + + + + {#snippet mainContent()} + + {/snippet} + diff --git a/frontend/src/routes/(app)/settings/roles/[id]/+page.ts b/frontend/src/routes/(app)/settings/roles/[id]/+page.ts new file mode 100644 index 0000000000..b65a97d204 --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/[id]/+page.ts @@ -0,0 +1,37 @@ +import { error, redirect } from '@sveltejs/kit'; +import { roleService } from '$lib/services/role-service'; +import { userIsGlobalAdmin } from '$lib/utils/redirect.util'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ parent, params }) => { + const { queryClient, user } = await parent(); + + // Role editing is reserved for global admins (matches RequireGlobalAdmin on + // PUT /roles/{id}). Guard here so the role payload isn't fetched for users + // who'll be redirected by the layout effect anyway. + if (!userIsGlobalAdmin(user)) { + throw redirect(302, '/settings/roles'); + } + + const [role, permissionsManifest] = await Promise.all([ + queryClient + .fetchQuery({ + queryKey: ['roles', 'detail', params.id], + queryFn: () => roleService.get(params.id) + }) + .catch(() => null), + queryClient.fetchQuery({ + queryKey: ['roles', 'permissions-manifest'], + queryFn: () => roleService.getPermissionsManifest() + }) + ]); + + if (!role) { + throw error(404, 'Role not found'); + } + + return { + role, + permissionsManifest + }; +}; diff --git a/frontend/src/routes/(app)/settings/roles/new/+page.svelte b/frontend/src/routes/(app)/settings/roles/new/+page.svelte new file mode 100644 index 0000000000..604bd5ba16 --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/new/+page.svelte @@ -0,0 +1,36 @@ + + + + {#snippet mainContent()} + + {/snippet} + diff --git a/frontend/src/routes/(app)/settings/roles/new/+page.ts b/frontend/src/routes/(app)/settings/roles/new/+page.ts new file mode 100644 index 0000000000..c9d89ac7fa --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/new/+page.ts @@ -0,0 +1,25 @@ +import { redirect } from '@sveltejs/kit'; +import { roleService } from '$lib/services/role-service'; +import { userIsGlobalAdmin } from '$lib/utils/redirect.util'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ parent }) => { + const { queryClient, user } = await parent(); + + // Role creation is reserved for global admins (matches RequireGlobalAdmin + // on POST /roles). Guard here so non-admins are bounced before the + // permissions manifest is fetched. + if (!userIsGlobalAdmin(user)) { + throw redirect(302, '/settings/roles'); + } + + const permissionsManifest = await queryClient.fetchQuery({ + queryKey: ['roles', 'permissions-manifest'], + queryFn: () => roleService.getPermissionsManifest() + }); + + return { + permissionsManifest, + role: null + }; +}; diff --git a/frontend/src/routes/(app)/settings/roles/roles-table.svelte b/frontend/src/routes/(app)/settings/roles/roles-table.svelte new file mode 100644 index 0000000000..84b472d17c --- /dev/null +++ b/frontend/src/routes/(app)/settings/roles/roles-table.svelte @@ -0,0 +1,308 @@ + + +{#snippet NameCell({ item }: { item: Role })} +
+ {item.name} +
+{/snippet} + +{#snippet DescriptionCell({ item }: { item: Role })} + {#if item.description} + {item.description} + {:else} + - + {/if} +{/snippet} + +{#snippet TypeCell({ item }: { item: Role })} + +{/snippet} + +{#snippet AssignedUsersCell({ item }: { item: Role })} + {item.assignedUserCount === 1 ? '1 user' : `${item.assignedUserCount} users`} +{/snippet} + +{#snippet PermissionsCell({ item }: { item: Role })} + + + {item.permissions.length} + + +

{item.permissions.join(', ')}

+
+
+{/snippet} + +{#snippet RoleMobileCardSnippet({ item, mobileFieldVisibility }: { item: Role; mobileFieldVisibility: MobileFieldVisibility })} + item.name} + subtitle={(item: Role) => ((mobileFieldVisibility['description'] ?? true) && item.description ? item.description : null)} + badges={[ + (item: Role) => ({ + variant: item.builtIn ? 'amber' : 'blue', + text: item.builtIn ? m.roles_built_in() : m.roles_custom() + }) + ]} + fields={[ + { + label: m.roles_col_permissions(), + getValue: (item: Role) => String(item.permissions.length), + icon: ShieldAlertIcon, + iconVariant: 'gray' as const, + show: mobileFieldVisibility['permissions'] ?? true + }, + { + label: m.roles_col_assigned_users(), + // TODO: i18n — roles_assigned_users_count has a malformed ICU plural in en.json + getValue: (item: Role) => (item.assignedUserCount === 1 ? '1 user' : `${item.assignedUserCount} users`), + icon: ShieldAlertIcon, + iconVariant: 'gray' as const, + show: mobileFieldVisibility['assignedUsers'] ?? true + } + ]} + rowActions={RowActions} + /> +{/snippet} + +{#snippet RowActions({ item }: { item: Role })} + {#if isAdmin} + + + {#snippet child({ props })} + + {m.common_open_menu()} + + + {/snippet} + + + + + goto(`/settings/roles/${item.id}`)}> + + {m.common_edit()} + + + + + handleDeleteRole(item)} + > + + {m.common_delete()} + + + + + + {/if} +{/snippet} + + { + requestOptions = options; + await onRolesChanged(); + return roles; + }} + {columns} + {mobileFields} + rowActions={RowActions} + mobileCard={RoleMobileCardSnippet} +/> diff --git a/frontend/src/routes/(app)/settings/users/+page.svelte b/frontend/src/routes/(app)/settings/users/+page.svelte index afa885a713..79a899b374 100644 --- a/frontend/src/routes/(app)/settings/users/+page.svelte +++ b/frontend/src/routes/(app)/settings/users/+page.svelte @@ -10,6 +10,8 @@ import type { CreateUser } from '$lib/types/user.type'; import { m } from '$lib/paraglide/messages'; import { userService } from '$lib/services/user-service'; + import { roleService } from '$lib/services/role-service'; + import userStore from '$lib/stores/user-store'; import { untrack } from 'svelte'; import { SettingsPageLayout, type SettingsActionButton } from '$lib/layouts/index.js'; @@ -26,6 +28,16 @@ let userToEdit = $state(null); + // Role assignment is admin-only on the server. Non-admins editing users can + // still update profile fields but must not call the assignments endpoint. + const isAdmin = $derived(userStore.isGlobalAdmin()); + + // availableRoleAssignments for the edit form — strip the source field, the + // editor only needs (roleId, environmentId) tuples. + const editingAssignments = $derived( + userToEdit?.roleAssignments?.map((a) => ({ roleId: a.roleId, environmentId: a.environmentId })) ?? [] + ); + let isLoading = $state({ creating: false, editing: false, @@ -47,7 +59,10 @@ isEditMode, userId }: { - user: Partial & { password?: string }; + user: Omit, 'roleAssignments'> & { + password?: string; + roleAssignments?: { roleId: string; environmentId?: string }[]; + }; isEditMode: boolean; userId?: string; }) { @@ -57,12 +72,18 @@ try { if (isEditMode && userId) { const safeUsername = userToEdit?.username || m.common_unknown(); - const result = await tryCatch(userService.update(userId, user)); + // Split: profile fields go to PUT /users/{id}; role assignments + // go to PUT /users/{id}/role-assignments (separate endpoint). + const { roleAssignments, ...profile } = user; + const result = await tryCatch(userService.update(userId, profile)); handleApiResultWithCallbacks({ result, message: m.common_update_failed({ resource: `${m.resource_user()} "${safeUsername}"` }), setLoadingState: (value) => (isLoading[loading] = value), onSuccess: async () => { + if (isAdmin && roleAssignments) { + await roleService.setUserAssignments(userId, { assignments: roleAssignments }); + } toast.success(m.common_update_success({ resource: `${m.resource_user()} "${safeUsername}"` })); users = await userService.getUsers(requestOptions); isDialogOpen.edit = false; @@ -82,8 +103,7 @@ username: user.username!, displayName: user.displayName, email: user.email, - password: user.password!, - roles: user.roles ?? ['user'] + password: user.password! }; const result = await tryCatch(userService.create(createUser)); @@ -91,7 +111,10 @@ result, message: m.common_create_failed({ resource: `${m.resource_user()} "${safeUsername}"` }), setLoadingState: (value) => (isLoading[loading] = value), - onSuccess: async () => { + onSuccess: async (created) => { + if (isAdmin && user.roleAssignments && created?.id) { + await roleService.setUserAssignments(created.id, { assignments: user.roleAssignments }); + } toast.success(m.common_create_success({ resource: `${m.resource_user()} "${safeUsername}"` })); users = await userService.getUsers(requestOptions); isDialogOpen.create = false; @@ -127,6 +150,7 @@ bind:users bind:selectedIds bind:requestOptions + roles={data.roles} onUsersChanged={async () => { users = await userService.getUsers(requestOptions); }} @@ -135,11 +159,22 @@ {/snippet} {#snippet additionalContent()} - + { } } satisfies SearchPaginationSortRequest); - const users = await queryClient.fetchQuery({ - queryKey: queryKeys.users.list(userRequestOptions), - queryFn: () => userService.getUsers(userRequestOptions) - }); + const [users, roles, environmentsPage] = await Promise.all([ + queryClient.fetchQuery({ + queryKey: queryKeys.users.list(userRequestOptions), + queryFn: () => userService.getUsers(userRequestOptions) + }), + queryClient.fetchQuery({ + queryKey: ['roles', 'all'], + queryFn: () => roleService.getAll(), + staleTime: 30_000 + }), + queryClient.fetchQuery({ + queryKey: ['environments', 'all-for-role-assignments'], + queryFn: () => environmentManagementService.getEnvironments({ pagination: { page: 1, limit: 1000 } }), + staleTime: 30_000 + }) + ]); return { users, - userRequestOptions + userRequestOptions, + roles, + environments: environmentsPage.data }; }; diff --git a/frontend/src/routes/(app)/settings/users/user-table.svelte b/frontend/src/routes/(app)/settings/users/user-table.svelte index fd0aaffce6..53b4b164ec 100644 --- a/frontend/src/routes/(app)/settings/users/user-table.svelte +++ b/frontend/src/routes/(app)/settings/users/user-table.svelte @@ -9,26 +9,33 @@ import { tryCatch } from '$lib/utils/try-catch'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { User } from '$lib/types/user.type'; + import type { Role } from '$lib/types/role.type'; + import { BUILT_IN_ROLE_ADMIN, BUILT_IN_ROLE_EDITOR, BUILT_IN_ROLE_DEPLOYER, BUILT_IN_ROLE_VIEWER } from '$lib/types/role.type'; import type { ColumnSpec, MobileFieldVisibility, BulkAction } from '$lib/components/arcane-table'; import { UniversalMobileCard } from '$lib/components/arcane-table'; import { m } from '$lib/paraglide/messages'; import { userService } from '$lib/services/user-service'; import { UserIcon, TrashIcon, EditIcon, EllipsisIcon } from '$lib/icons'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { users = $bindable(), selectedIds = $bindable(), requestOptions = $bindable(), + roles, onUsersChanged, onEditUser }: { users: Paginated; selectedIds: string[]; requestOptions: SearchPaginationSortRequest; + roles: Role[]; onUsersChanged: () => Promise; onEditUser: (user: User) => void; } = $props(); + const rolesById = $derived(new Map(roles.map((r) => [r.id, r]))); + let isLoading = $state({ removing: false }); @@ -118,27 +125,72 @@ }); } - function getRoleBadgeVariant(roles: string[]) { - if (roles?.includes('admin')) return 'red'; - return 'green'; + type BadgeVariant = 'red' | 'blue' | 'purple' | 'gray' | 'green'; + + /** True when the user holds the built-in Admin role globally. */ + function isGlobalAdmin(user: User): boolean { + return !!user.roleAssignments?.some((a) => a.roleId === BUILT_IN_ROLE_ADMIN && !a.environmentId); + } + + /** Color variant for a role badge keyed off the role ID (not its localized name). */ + function roleVariant(roleId: string): BadgeVariant { + switch (roleId) { + case BUILT_IN_ROLE_ADMIN: + return 'red'; + case BUILT_IN_ROLE_EDITOR: + return 'blue'; + case BUILT_IN_ROLE_DEPLOYER: + return 'purple'; + case BUILT_IN_ROLE_VIEWER: + return 'gray'; + default: + return 'green'; // custom roles + } } - function getRoleText(roles: string[]) { - if (roles?.includes('admin')) return m.common_admin(); - return m.common_user(); + function roleName(roleId: string): string { + return rolesById.get(roleId)?.name ?? m.common_unknown(); + } + + /** + * Resolves what to render in the Role column for a given user. Three states: + * - no assignments → single "No access" gray badge + * - holds global Admin → single "Admin" red badge (collapses any other rows) + * - everything else → one badge per distinct role, with a tooltip + * listing which environments each scope applies to + */ + function rolesSummary(user: User): { text: string; variant: BadgeVariant; tooltip?: string }[] { + const assignments = user.roleAssignments ?? []; + if (assignments.length === 0) { + return [{ text: m.users_role_summary_none(), variant: 'gray' }]; + } + if (isGlobalAdmin(user)) { + return [{ text: m.common_admin(), variant: 'red' }]; + } + // Group by roleId so a user assigned the same role on multiple envs gets + // one badge with a tooltip listing the env count. + const byRole = new Map(); + for (const a of assignments) { + byRole.set(a.roleId, (byRole.get(a.roleId) ?? 0) + 1); + } + return Array.from(byRole.entries()).map(([roleId, count]) => ({ + text: roleName(roleId), + variant: roleVariant(roleId), + tooltip: count > 1 ? m.users_role_summary_env_count({ count }) : undefined + })); } const columns = [ { accessorKey: 'username', title: m.common_username(), sortable: true, cell: UsernameCell }, { accessorKey: 'displayName', title: m.common_display_name(), sortable: true, cell: DisplayNameCell }, { accessorKey: 'email', title: m.common_email(), sortable: true, cell: EmailCell }, - { accessorKey: 'roles', title: m.common_role(), sortable: true, cell: RoleCell } + { accessorKey: 'roleAssignments', title: m.common_role(), sortable: false, cell: RoleCell } ] satisfies ColumnSpec[]; const mobileFields = [ { id: 'displayName', label: m.common_display_name(), defaultVisible: true }, { id: 'email', label: m.common_email(), defaultVisible: true }, - { id: 'roles', label: m.common_role(), defaultVisible: true } + { id: 'roleAssignments', label: m.common_role(), defaultVisible: true } ]; const bulkActions = $derived.by(() => [ @@ -169,7 +221,11 @@ {/snippet} {#snippet RoleCell({ item }: { item: User })} - +
+ {#each rolesSummary(item) as badge (badge.text)} + + {/each} +
{/snippet} {#snippet UserMobileCardSnippet({ item, mobileFieldVisibility }: { item: User; mobileFieldVisibility: MobileFieldVisibility })} @@ -179,13 +235,18 @@ title={(item: User) => item.username} subtitle={(item: User) => ((mobileFieldVisibility['email'] ?? true) && item.email ? item.email : null)} badges={[ - (item: User) => - (mobileFieldVisibility['roles'] ?? true) - ? { - variant: getRoleBadgeVariant(item.roles) === 'red' ? 'red' : 'green', - text: getRoleText(item.roles) - } - : null + (item: User) => { + if (!(mobileFieldVisibility['roleAssignments'] ?? true)) return null; + const summary = rolesSummary(item); + // Mobile cards take a single badge; show the first (or a "+N" hint + // when the user has multiple distinct roles). + const [head, ...rest] = summary; + if (!head) return null; + if (rest.length === 0) { + return { variant: head.variant, text: head.text }; + } + return { variant: head.variant, text: `${head.text} +${rest.length}` }; + } ]} fields={[ { @@ -213,22 +274,26 @@ {#if !item.oidcSubjectId} - onEditUser(item)}> - - {m.common_edit()} - + + onEditUser(item)}> + + {m.common_edit()} + - + + {/if} - handleDeleteUser(item)} - > - - {m.common_delete()} - + + handleDeleteUser(item)} + > + + {m.common_delete()} + + diff --git a/frontend/src/routes/(app)/settings/webhooks/webhook-table.svelte b/frontend/src/routes/(app)/settings/webhooks/webhook-table.svelte index 21f82aaf1c..d1c1e6ce60 100644 --- a/frontend/src/routes/(app)/settings/webhooks/webhook-table.svelte +++ b/frontend/src/routes/(app)/settings/webhooks/webhook-table.svelte @@ -15,6 +15,7 @@ import { webhookService } from '$lib/services/webhook-service'; import { TrashIcon, EllipsisIcon, GlobeIcon } from '$lib/icons'; import * as m from '$lib/paraglide/messages.js'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let { webhooks = $bindable(), @@ -298,14 +299,18 @@ - handleToggleWebhook(item)} disabled={isLoading.toggling}> - {item.enabled ? m.webhook_disable() : m.webhook_enable()} - + + handleToggleWebhook(item)} disabled={isLoading.toggling}> + {item.enabled ? m.webhook_disable() : m.webhook_enable()} + + - handleDeleteWebhook(item.id, item.name)}> - - {m.common_delete()} - + + handleDeleteWebhook(item.id, item.name)}> + + {m.common_delete()} + + diff --git a/frontend/src/routes/(app)/swarm/cluster/+page.svelte b/frontend/src/routes/(app)/swarm/cluster/+page.svelte index ee722745cf..fc43a3bfd0 100644 --- a/frontend/src/routes/(app)/swarm/cluster/+page.svelte +++ b/frontend/src/routes/(app)/swarm/cluster/+page.svelte @@ -12,7 +12,8 @@ import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; import { m } from '$lib/paraglide/messages'; import { swarmService } from '$lib/services/swarm-service'; - import userStore from '$lib/stores/user-store'; + import { hasPermission } from '$lib/utils/permissions.util'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; import type { SwarmInfo, SwarmInitRequest, @@ -22,13 +23,12 @@ } from '$lib/types/swarm.type'; import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; import { tryCatch } from '$lib/utils/try-catch'; - import { fromStore } from 'svelte/store'; import { toast } from 'svelte-sonner'; let {}: PageProps = $props(); - const storeUser = fromStore(userStore); - const isAdmin = $derived(!!storeUser.current?.roles?.includes('admin')); + const currentEnvId = $derived(environmentStore.selected?.id); + const canManageSwarm = $derived(hasPermission('swarm:nodes', currentEnvId)); let swarmInfo = $state(null); let joinTokens = $state(null); @@ -304,7 +304,7 @@ size="sm" customLabel={m.common_actions()} onclick={() => (securityDialogOpen = true)} - disabled={!isAdmin} + disabled={!canManageSwarm} /> @@ -336,7 +336,7 @@ size="sm" customLabel={m.swarm_cluster_rotate_tokens()} onclick={handleRotateTokens} - disabled={!isAdmin || !isSwarmInitialized || isLoading.rotateTokens} + disabled={!canManageSwarm || !isSwarmInitialized || isLoading.rotateTokens} loading={isLoading.rotateTokens} /> @@ -440,7 +440,7 @@ action="create" customLabel={m.swarm_cluster_initialize_action()} onclick={handleInit} - disabled={!isAdmin || isLoading.init} + disabled={!canManageSwarm || isLoading.init} loading={isLoading.init} /> @@ -466,7 +466,7 @@ action="create" customLabel={m.swarm_cluster_join_action()} onclick={handleJoin} - disabled={!isAdmin || isLoading.join} + disabled={!canManageSwarm || isLoading.join} loading={isLoading.join} /> @@ -512,7 +512,7 @@ action="save" customLabel={m.swarm_cluster_update_spec_action()} onclick={handleUpdateSpec} - disabled={!isAdmin || isLoading.updateSpec} + disabled={!canManageSwarm || isLoading.updateSpec} loading={isLoading.updateSpec} /> @@ -543,7 +543,7 @@ action="save" customLabel={m.swarm_cluster_unlock_action()} onclick={handleUnlock} - disabled={!isAdmin || isLoading.unlock} + disabled={!canManageSwarm || isLoading.unlock} loading={isLoading.unlock} /> @@ -558,7 +558,7 @@ action="remove" customLabel={m.swarm_cluster_leave_action()} onclick={handleLeave} - disabled={!isAdmin || isLoading.leave} + disabled={!canManageSwarm || isLoading.leave} loading={isLoading.leave} /> diff --git a/frontend/src/routes/(app)/swarm/configs/+page.svelte b/frontend/src/routes/(app)/swarm/configs/+page.svelte index 8f681ae814..0c1ef62f04 100644 --- a/frontend/src/routes/(app)/swarm/configs/+page.svelte +++ b/frontend/src/routes/(app)/swarm/configs/+page.svelte @@ -9,19 +9,20 @@ import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; import { m } from '$lib/paraglide/messages'; import { swarmService } from '$lib/services/swarm-service'; - import userStore from '$lib/stores/user-store'; + import { hasPermission } from '$lib/utils/permissions.util'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; import type { SwarmConfigSummary } from '$lib/types/swarm.type'; import { handleApiResultWithCallbacks } from '$lib/utils/api.util'; import { tryCatch } from '$lib/utils/try-catch'; - import { fromStore } from 'svelte/store'; import { toast } from 'svelte-sonner'; import { formatDistanceToNow } from 'date-fns'; import { openConfirmDialog } from '$lib/components/confirm-dialog'; + import IfPermitted from '$lib/components/if-permitted.svelte'; let {}: PageProps = $props(); - const storeUser = fromStore(userStore); - const isAdmin = $derived(!!storeUser.current?.roles?.includes('admin')); + const currentEnvId = $derived(environmentStore.selected?.id); + const canManageConfigs = $derived(hasPermission('swarm:configs', currentEnvId)); let configs = $state([]); let selectedConfigId = $state(''); @@ -195,13 +196,15 @@ placeholder={m.swarm_configs_data_placeholder()} class="font-mono text-xs" /> - + + + @@ -252,7 +255,7 @@ customLabel={m.swarm_configs_delete_button()} icon={TrashIcon} onclick={() => removeConfig(config)} - disabled={!isAdmin || isLoading.delete} + disabled={!canManageConfigs || isLoading.delete} loading={isLoading.delete} /> diff --git a/frontend/src/routes/(app)/swarm/nodes/nodes-table.svelte b/frontend/src/routes/(app)/swarm/nodes/nodes-table.svelte index e03343d18a..5ec062b946 100644 --- a/frontend/src/routes/(app)/swarm/nodes/nodes-table.svelte +++ b/frontend/src/routes/(app)/swarm/nodes/nodes-table.svelte @@ -26,8 +26,8 @@ import { tryCatch } from '$lib/utils/try-catch'; import { extractApiErrorMessage, handleApiResultWithCallbacks } from '$lib/utils/api.util'; import { goto } from '$app/navigation'; - import userStore from '$lib/stores/user-store'; - import { fromStore } from 'svelte/store'; + import { hasPermission } from '$lib/utils/permissions.util'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; import SwarmNodeAgentDialog from './swarm-node-agent-dialog.svelte'; import SwarmNodeLabelDialog from './swarm-node-label-dialog.svelte'; import { getSwarmNodeAgentActionLabel, getSwarmNodeAgentLabel, getSwarmNodeAgentVariant } from './agent-status'; @@ -40,8 +40,8 @@ requestOptions: SearchPaginationSortRequest; } = $props(); - const storeUser = fromStore(userStore); - const isAdmin = $derived(!!storeUser.current?.roles?.includes('admin')); + const currentEnvId = $derived(environmentStore.selected?.id); + const canManageNodes = $derived(hasPermission('swarm:nodes', currentEnvId)); let isLoading = $state(false); let isAgentDialogOpen = $state(false); let selectedNode = $state(null); @@ -275,7 +275,7 @@ {#each Object.entries(item.labels ?? {}) as [key, value] (key)}
- {#if isAdmin} + {#if canManageNodes}
{/each} - {#if isAdmin} + {#if canManageNodes} +{:else} + +{/if} diff --git a/frontend/src/lib/components/activity/activity-center.svelte b/frontend/src/lib/components/activity/activity-center.svelte new file mode 100644 index 0000000000..d7e51cc468 --- /dev/null +++ b/frontend/src/lib/components/activity/activity-center.svelte @@ -0,0 +1,142 @@ + + + +
+
+ {#if activityStore.activeCount > 0} + + {m.activity_active_count({ count: activityStore.activeCount })} + + {/if} +
+ +
+
+ {#each filters as filter (filter)} + + {/each} +
+ + + + +
+
+ +
+ {#if activityStore.loading && activityStore.activities.length === 0} +
+
+
+
+ {:else if activityStore.filteredActivities.length === 0} +
+
+
+
+ {:else} +
+ {#each activityStore.filteredActivities as activity (activity.id)} + {@const expanded = activityStore.isExpanded(activity.id)} + activityStore.setActivityExpanded(activity.id, open)}> + + + + + + + + {/each} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/activity/activity-detail-panel.svelte b/frontend/src/lib/components/activity/activity-detail-panel.svelte new file mode 100644 index 0000000000..eec062fa8f --- /dev/null +++ b/frontend/src/lib/components/activity/activity-detail-panel.svelte @@ -0,0 +1,228 @@ + + +
+
+
+
+
+
+
+
+
+

{activityTypeLabel(liveActivity.type)}

+ +
+

{activityTarget}

+
+
+
+ +
+
+ {liveActivity.step || m.activity_step_unknown()} + + {#if hasProgress} + {m.activity_progress_percent({ progress: progressValue })} + {:else} + {m.common_live()} + {/if} + +
+ +
+ +
+
+ {m.common_started()} + {formatDateTimeInternal(liveActivity.startedAt)} +
+ +
+ {m.common_finished()} + {formatDateTimeInternal(liveActivity.endedAt)} +
+ +
+ {m.activity_duration()} + {formatDurationInternal(liveActivity)} +
+ {#if sourceEnvironmentName} + +
+ {m.activity_source_environment()} + {sourceEnvironmentName} +
+ {/if} + {#if startedByName} + +
+ {m.activity_started_by_label()} + {startedByName} +
+ {/if} +
+ + {#if liveActivity.error} +
+ {liveActivity.error} +
+ {/if} +
+ +
+
+
+
+ + {m.activity_copy_output()} + +
+ +
+ {#if isDetailError && messages.length === 0} +
+ {m.activity_output_load_failed()} + +
+ {:else if isLoading && messages.length === 0} +
+
+ {:else if messages.length === 0} +
+ {m.activity_output_empty()} +
+ {:else} + {#each messages as message (message.id)} +
+ {formatDateTimeInternal(message.createdAt)} + + {message.level.toUpperCase()} + + {message.message} +
+ {/each} + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/activity/activity-labels.ts b/frontend/src/lib/components/activity/activity-labels.ts new file mode 100644 index 0000000000..0bc279341f --- /dev/null +++ b/frontend/src/lib/components/activity/activity-labels.ts @@ -0,0 +1,140 @@ +import { m } from '$lib/paraglide/messages'; +import type { ActivityFilter, ActivityStatus, ActivityType } from '$lib/types/activity.type'; +import type { IconType } from '$lib/icons'; +import { + ActivityIcon, + DownloadIcon, + HammerIcon, + RedeployIcon, + RefreshIcon, + RestartIcon, + ScanIcon, + StartIcon, + StopIcon, + TrashIcon +} from '$lib/icons'; + +export type ActivityBadgeVariant = 'red' | 'green' | 'blue' | 'gray' | 'amber' | 'purple'; + +export function activityStatusLabel(status: ActivityStatus): string { + switch (status) { + case 'queued': + return m.activity_status_queued(); + case 'running': + return m.common_running(); + case 'success': + return m.common_success(); + case 'failed': + return m.common_failed(); + case 'cancelled': + return m.activity_status_cancelled(); + } +} + +export function activityStatusVariant(status: ActivityStatus): ActivityBadgeVariant { + switch (status) { + case 'queued': + return 'amber'; + case 'running': + return 'blue'; + case 'success': + return 'green'; + case 'failed': + return 'red'; + case 'cancelled': + return 'gray'; + } +} + +export function activityTypeLabel(type: ActivityType): string { + switch (type) { + case 'image_pull': + return m.activity_type_image_pull(); + case 'image_build': + return m.activity_type_image_build(); + case 'image_update_check': + return m.activity_type_image_update_check(); + case 'project_pull': + return m.activity_type_project_pull(); + case 'project_build': + return m.activity_type_project_build(); + case 'project_deploy': + return m.activity_type_project_deploy(); + case 'project_redeploy': + return m.activity_type_project_redeploy(); + case 'project_down': + return m.activity_type_project_down(); + case 'project_restart': + return m.activity_type_project_restart(); + case 'project_destroy': + return m.activity_type_project_destroy(); + case 'container_start': + return m.activity_type_container_start(); + case 'container_stop': + return m.activity_type_container_stop(); + case 'container_restart': + return m.activity_type_container_restart(); + case 'container_redeploy': + return m.activity_type_container_redeploy(); + case 'container_delete': + return m.activity_type_container_delete(); + case 'vulnerability_scan': + return m.activity_type_vulnerability_scan(); + case 'auto_update': + return m.activity_type_auto_update(); + case 'system_prune': + return m.activity_type_system_prune(); + case 'resource_action': + return m.activity_type_resource_action(); + } +} + +export function activityTypeIcon(type: ActivityType): IconType { + switch (type) { + case 'image_pull': + case 'project_pull': + return DownloadIcon; + case 'image_build': + case 'project_build': + return HammerIcon; + case 'image_update_check': + return RefreshIcon; + case 'project_deploy': + return ActivityIcon; + case 'container_start': + return StartIcon; + case 'project_redeploy': + case 'container_redeploy': + return RedeployIcon; + case 'project_down': + case 'container_stop': + return StopIcon; + case 'project_restart': + case 'container_restart': + return RestartIcon; + case 'project_destroy': + case 'container_delete': + return TrashIcon; + case 'vulnerability_scan': + return ScanIcon; + case 'auto_update': + return RefreshIcon; + case 'system_prune': + return TrashIcon; + case 'resource_action': + return ActivityIcon; + default: + return ActivityIcon; + } +} + +export function activityFilterLabel(filter: ActivityFilter): string { + switch (filter) { + case 'running': + return m.common_running(); + case 'failed': + return m.common_failed(); + case 'completed': + return m.activity_filter_completed(); + } +} diff --git a/frontend/src/lib/components/activity/activity-list-item.svelte b/frontend/src/lib/components/activity/activity-list-item.svelte new file mode 100644 index 0000000000..cb01ec760a --- /dev/null +++ b/frontend/src/lib/components/activity/activity-list-item.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+
+
+
+
+
+ {activityTypeLabel(activity.type)} + {#if relativeTime} + · {relativeTime} + {/if} +
+
{targetName}
+
+ {#if sourceEnvironmentName} + {sourceEnvironmentName} + {/if} + {#if startedByName} + · + {m.activity_started_by({ user: startedByName })} + {/if} +
+
+ +
+ +
+
{subtitle}
+ {#if isActive && !expanded} +
+ + + {#if hasProgress} + {m.activity_progress_percent({ progress: progressValue })} + {:else} + {m.common_live()} + {/if} + +
+ {/if} +
+
+ +
+
+
diff --git a/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte b/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte index f9776e9260..d516c57072 100644 --- a/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte +++ b/frontend/src/lib/components/file-browser/CreateFolderDialog.svelte @@ -5,6 +5,7 @@ import { Label } from '$lib/components/ui/label'; import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages.js'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { open = $bindable(false), @@ -13,7 +14,7 @@ }: { open: boolean; currentPath: string; - onCreate: (folderName: string) => Promise; + onCreate: (folderName: string) => Promise; } = $props(); let folderName = $state(''); @@ -25,8 +26,8 @@ loading = true; try { - await onCreate(folderName); - toast.success(m.common_create_success({ resource: folderName })); + const result = await onCreate(folderName); + toast.success(m.common_create_success({ resource: folderName }), activityToastOptions(extractActivityId(result))); open = false; folderName = ''; } catch (e: any) { diff --git a/frontend/src/lib/components/file-browser/FileList.svelte b/frontend/src/lib/components/file-browser/FileList.svelte index 608ea42011..cc10c06daf 100644 --- a/frontend/src/lib/components/file-browser/FileList.svelte +++ b/frontend/src/lib/components/file-browser/FileList.svelte @@ -22,6 +22,7 @@ import { ArcaneButton } from '$lib/components/arcane-button'; import { bytes } from '$lib/utils/formatting'; import { format } from 'date-fns'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { files, @@ -40,7 +41,7 @@ persistKey?: string; onNavigate: (path: string) => void; onRefresh: () => void; - onDelete?: (file: FileEntry) => Promise; + onDelete?: (file: FileEntry) => Promise; onDownload: (file: FileEntry) => Promise; onPreview: (file: FileEntry) => void; onRestoreFromBackup?: (file: FileEntry) => void; @@ -111,8 +112,8 @@ destructive: true, action: async () => { try { - await onDelete(file); - toast.success(m.common_delete_success({ resource: file.name })); + const result = await onDelete(file); + toast.success(m.common_delete_success({ resource: file.name }), activityToastOptions(extractActivityId(result))); onRefresh(); } catch (e: any) { toast.error(e.message || m.common_delete_failed({ resource: file.name })); diff --git a/frontend/src/lib/components/file-browser/FileUploadDialog.svelte b/frontend/src/lib/components/file-browser/FileUploadDialog.svelte index ff2c22414b..e52aacfb53 100644 --- a/frontend/src/lib/components/file-browser/FileUploadDialog.svelte +++ b/frontend/src/lib/components/file-browser/FileUploadDialog.svelte @@ -5,6 +5,7 @@ import { toast } from 'svelte-sonner'; import * as m from '$lib/paraglide/messages.js'; import { CloseIcon } from '$lib/icons'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { open = $bindable(false), @@ -13,7 +14,7 @@ }: { open: boolean; currentPath: string; - onUpload: (file: File) => Promise; + onUpload: (file: File) => Promise; } = $props(); let files = $state([]); @@ -35,10 +36,11 @@ if (files.length === 0) return; uploading = true; try { + let lastResult: unknown; for (const file of files) { - await onUploadAction(file); + lastResult = await onUploadAction(file); } - toast.success(m.common_success()); + toast.success(m.common_success(), activityToastOptions(extractActivityId(lastResult))); open = false; files = []; } catch (e: any) { diff --git a/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte b/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte index c4f3c27acc..3bd3b8a936 100644 --- a/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte +++ b/frontend/src/lib/components/file-browser/GenericFileBrowser.svelte @@ -3,13 +3,13 @@ export interface FileProvider { list: (path: string) => Promise; - mkdir: (path: string) => Promise; - upload: (path: string, file: File) => Promise; - delete: (path: string) => Promise; + mkdir: (path: string) => Promise; + upload: (path: string, file: File) => Promise; + delete: (path: string) => Promise; download: (path: string) => Promise; getContent: (path: string) => Promise<{ content: string }>; listBackups?: () => Promise; - restoreFromBackup?: (backupId: string, path: string) => Promise; + restoreFromBackup?: (backupId: string, path: string) => Promise; backupHasPath?: (backupId: string, path: string) => Promise; } @@ -35,6 +35,7 @@ import { environmentStore } from '$lib/stores/environment.store.svelte'; import { hasPermission } from '$lib/utils/auth'; import IfPermitted from '$lib/components/if-permitted.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { provider, rootLabel, persistKey }: { provider: FileProvider; rootLabel?: string; persistKey?: string } = $props(); @@ -120,8 +121,8 @@ if (!restoreTarget || !provider.restoreFromBackup || !selectedBackupId) return; restoringFile = true; try { - await provider.restoreFromBackup(selectedBackupId, restoreTarget.path); - toast.success(m.volumes_backup_file_restore_success()); + const result = await provider.restoreFromBackup(selectedBackupId, restoreTarget.path); + toast.success(m.volumes_backup_file_restore_success(), activityToastOptions(extractActivityId(result))); showRestoreFile = false; // Refresh the file list to show the restored file await loadFiles(currentPath); diff --git a/frontend/src/lib/components/image-update-item.svelte b/frontend/src/lib/components/image-update-item.svelte index b0dd214042..ea848b140c 100644 --- a/frontend/src/lib/components/image-update-item.svelte +++ b/frontend/src/lib/components/image-update-item.svelte @@ -10,6 +10,7 @@ import { ArrowRightIcon, RefreshIcon, AlertIcon, VerifiedCheckIcon, ApiKeyIcon, CircleArrowUpIcon, BoxIcon } from '$lib/icons'; import { createQuery } from '@tanstack/svelte-query'; import UpdateStatusPopover from '$lib/components/update-status-popover.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; interface Props { updateInfo?: ImageUpdateData; @@ -140,11 +141,12 @@ const result = await imageUpdateQuery.refetch(); if (result.data) { onUpdated?.(result.data); + const toastOptions = activityToastOptions(extractActivityId(result.data)); if (result.data.error) { - toast.error(result.data.error || m.images_update_check_failed()); + toast.error(result.data.error || m.images_update_check_failed(), toastOptions); } else { - toast.success(m.images_update_check_completed()); + toast.success(m.images_update_check_completed(), toastOptions); } return; } diff --git a/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte b/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte index 9b4f6f6b93..24539ce4f4 100644 --- a/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte +++ b/frontend/src/lib/components/mobile-nav/mobile-nav-sheet.svelte @@ -7,6 +7,7 @@ import { m } from '$lib/paraglide/messages'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import MobileUserCard from './mobile-user-card.svelte'; + import ActivityCenterTrigger from '$lib/components/activity/activity-center-trigger.svelte'; import * as Drawer from '$lib/components/ui/drawer/index.js'; import { queryKeys } from '$lib/query/query-keys'; import systemUpgradeService from '$lib/services/api/system-upgrade-service'; @@ -120,6 +121,7 @@ {#if memoizedUser} {/if} +
@@ -357,7 +359,8 @@ {#if versionInformation}

- Arcane {versionInformation.displayVersion ?? versionInformation.currentVersion} + {m.layout_title()} + {versionInformation.displayVersion ?? versionInformation.currentVersion}

{#if shouldShowBanner} diff --git a/frontend/src/lib/components/progress-popover.svelte b/frontend/src/lib/components/progress-popover.svelte deleted file mode 100644 index de1a925b19..0000000000 --- a/frontend/src/lib/components/progress-popover.svelte +++ /dev/null @@ -1,316 +0,0 @@ - - -{#snippet content()} - - - {#if error} - - {:else if isComplete && !loading} - - {:else} - - {/if} - - - {displayTitle} - - {#if error} - {error} - {:else if mode !== 'pull' && layerStats.total > 0} - {hasReachedComplete ? 100 : percent}% · {genericStatus} - - · {m.progress_layers_status({ completed: layerStats.completed, total: layerStats.total })} - {:else if mode !== 'pull'} - {#if hasStructuredProgress || loading || hasReachedComplete} - {hasReachedComplete ? 100 : percent}% · {genericStatus} - {:else} - {genericStatus} - {/if} - {:else if layerStats.total > 0} - {aggregateStatus || subtitle} - - · {m.progress_layers_status({ completed: layerStats.completed, total: layerStats.total })} - {:else} - {hasReachedComplete ? 100 : percent}% · {aggregateStatus || subtitle} - {/if} - - - {#if loading && onCancel} - - - - {/if} - {#if !error} - - - - {/if} - - - {#if Object.keys(layers).length > 0 && !error} - - - {m.progress_show_layers()} - - - -
- {#each Object.entries(layers) as [id, layer] (id)} - {@const layerStatus = sanitizeLogText(layer.status || '')} - {@const phase = hasReachedComplete ? 'complete' : getLayerPhase(layerStatus)} - {@const layerPercent = - phase === 'complete' ? 100 : layer.total > 0 ? Math.round((layer.current / layer.total) * 100) : 0} -
-
- {id.slice(0, 12)} - - {#if phase === 'complete'} - ✓ - {:else if layer.total > 0} - {layerPercent}% - {:else} - {layerStatus} - {/if} - -
- -
- {/each} -
-
-
- {/if} - - {#if (showOutputPanel || outputLines.length > 0) && !error} -
-
- {m.build_output()} - {outputLines.length} -
-
{#if outputLines.length > 0}{#each outputLines as line, i (i)}{line}{/each}{:else}{outputPlaceholder}{/if}
-
- {/if} -{/snippet} -{#if isMobile.current} - - - {#snippet child({ props })} - - {@render children()} - - {/snippet} - - - {@render content()} - - -{:else} - - - {#snippet child({ props })} - - {@render children()} - - {/snippet} - - - - {@render content()} - - - -{/if} diff --git a/frontend/src/lib/components/sheets/image-pull-sheet.svelte b/frontend/src/lib/components/sheets/image-pull-sheet.svelte index c7603e169a..fe475cd30b 100644 --- a/frontend/src/lib/components/sheets/image-pull-sheet.svelte +++ b/frontend/src/lib/components/sheets/image-pull-sheet.svelte @@ -2,28 +2,11 @@ import * as ResponsiveDialog from '$lib/components/ui/responsive-dialog/index.js'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import FormInput from '$lib/components/form/form-input.svelte'; - import { Progress } from '$lib/components/ui/progress/index.js'; - import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { z } from 'zod/v4'; import { createForm, preventDefault } from '$lib/utils/settings'; import { toast } from 'svelte-sonner'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { m } from '$lib/paraglide/messages'; - import { cn } from '$lib/utils.js'; - import { - type LayerProgress, - type PullPhase, - calculateOverallProgress, - areAllLayersComplete, - updateLayerFromStreamData, - extractErrorMessage, - getLayerStats, - getPullPhase, - showImageLayersState, - isIndeterminatePhase, - getAggregateStatus - } from '$lib/utils/docker'; - import { ArrowDownIcon } from '$lib/icons'; type ImagePullFormProps = { open: boolean; @@ -45,53 +28,12 @@ let { inputs, ...form } = $derived(createForm(formSchema, formData)); let isPulling = $state(false); - let pullProgress = $state(0); - let pullStatusText = $state(''); - let pullError = $state(''); - let layerProgress = $state>({}); - let hasReachedComplete = $state(false); - let currentImageName = $state(''); - const layerStats = $derived(getLayerStats(layerProgress, hasReachedComplete)); - const aggregateStatus = $derived(getAggregateStatus(layerProgress, pullStatusText, hasReachedComplete)); - const showPullUI = $derived(isPulling || hasReachedComplete || !!pullError); - const isIndeterminate = $derived(isIndeterminatePhase(layerProgress, pullProgress)); - let prevOpen = $state(false); - - $effect(() => { - if (prevOpen && !open && !isPulling) { - // Sheet just closed, reset state and form - resetState(); - $inputs.imageRef.value = ''; - $inputs.tag.value = 'latest'; - } - prevOpen = open; - }); - - function getLayerPhase(status: string): PullPhase { - return getPullPhase(status, false, false); - } - - function resetState() { - isPulling = false; - pullProgress = 0; - pullStatusText = ''; - pullError = ''; - layerProgress = {}; - hasReachedComplete = false; - currentImageName = ''; - } - - function updateProgress() { - pullProgress = calculateOverallProgress(layerProgress); - } async function handleSubmit() { const data = form.validate(); if (!data) return; - resetState(); isPulling = true; - pullStatusText = m.images_pull_initiating(); let imageName = data.imageRef.trim(); let imageTag = data.tag?.trim() || 'latest'; @@ -99,7 +41,6 @@ if (imageName.includes(':')) { const lastColonIndex = imageName.lastIndexOf(':'); const possibleTag = imageName.substring(lastColonIndex + 1).trim(); - // Only split if the part after the last colon looks like a tag (not a port number in a registry URL) if (possibleTag && !possibleTag.includes('/')) { imageName = imageName.substring(0, lastColonIndex); imageTag = possibleTag; @@ -107,16 +48,12 @@ } const fullImageName = `${imageName}:${imageTag}`; - currentImageName = fullImageName; const envId = await environmentStore.getCurrentEnvironmentId(); - pullStatusText = m.images_pull_initiating(); try { const response = await fetch(`/api/environments/${envId}/images/pull`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageName: fullImageName }) }); @@ -124,101 +61,73 @@ const errorData = await response.json().catch(() => ({ data: { message: m.images_pull_server_error() } })); - const errorMessage = errorData.data?.message || errorData.error || errorData.message || `${m.images_pull_server_error()}: HTTP ${response.status}`; - throw new Error(errorMessage); } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) { - pullStatusText = m.images_pull_processing_final_layers(); - break; - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.trim() === '') continue; - try { - const data = JSON.parse(line); - - const errorMsg = extractErrorMessage(data, m.images_pull_stream_error()); - if (errorMsg) { - console.error('Error in stream:', errorMsg); - pullError = errorMsg; - pullStatusText = m.images_pull_failed_with_error({ error: pullError }); - continue; - } - - if (data.status) pullStatusText = data.status; - layerProgress = updateLayerFromStreamData(layerProgress, data); - updateProgress(); - } catch (e: any) { - console.warn('Failed to parse stream line or process data:', line, e); - } - } - } - - updateProgress(); - if (!pullError && pullProgress < 100 && areAllLayersComplete(layerProgress)) { - pullProgress = 100; - } - - if (pullError) { - throw new Error(pullError); - } - - hasReachedComplete = true; - pullProgress = 100; - pullStatusText = m.images_pull_success({ repoTag: fullImageName }); - toast.success(m.images_pull_success({ repoTag: fullImageName })); - onPullFinished(true, fullImageName); + open = false; + isPulling = false; - // Close sheet after a brief delay to show success state - // State will be reset by handleOpenChange when sheet closes - setTimeout(() => { - open = false; - }, 1500); + drainPullStream(response.body, fullImageName); } catch (error: any) { - console.error('Pull image error:', error); const message = error.message || m.images_pull_unexpected_error(); - pullError = message; - pullStatusText = m.images_pull_failed_with_error({ error: message }); toast.error(message); onPullFinished(false, fullImageName, message); - } finally { isPulling = false; } } + function drainPullStream(body: ReadableStream, fullImageName: string) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed?.error) { + const errMsg = + typeof parsed.error === 'string' ? parsed.error : parsed.error.message || m.images_pull_stream_error(); + toast.error(errMsg); + onPullFinished(false, fullImageName, errMsg); + return; + } + } catch { + // ignore non-JSON lines + } + } + } + onPullFinished(true, fullImageName); + } catch (error: any) { + const message = error.message || m.images_pull_unexpected_error(); + toast.error(message); + onPullFinished(false, fullImageName, message); + } + })(); + } + function handleOpenChange(newOpenState: boolean) { if (!newOpenState && isPulling) { - toast.info(m.images_pull_in_progress_toast()); - open = true; // Keep it open return; } open = newOpenState; - if (!newOpenState) { - // Reset when closing (if we get here, isPulling is false) - resetState(); - $inputs.imageRef.value = ''; - $inputs.tag.value = 'latest'; - } else { - // Also reset when opening to ensure clean state - resetState(); + if (!newOpenState || newOpenState) { $inputs.imageRef.value = ''; $inputs.tag.value = 'latest'; } @@ -226,138 +135,36 @@ {#snippet children()} - {#if showPullUI} - -
- {#if pullError} -
-

{m.image_update_error_label()}

-

{pullError}

-
- {:else} - -
-
-

- {#if hasReachedComplete} - {m.progress_pull_completed()} - {:else if layerStats.total > 0} - - {aggregateStatus} - - - {m.progress_layers_status({ completed: layerStats.completed, total: layerStats.total })} - - - {:else} - {aggregateStatus || m.common_action_pulling()} - {/if} -

- {#if !isIndeterminate || hasReachedComplete} -

{Math.round(hasReachedComplete ? 100 : pullProgress)}%

- {/if} -
- -
- - {#if Object.keys(layerProgress).length > 0} - - - {m.progress_show_layers()} - - - -
- {#each Object.entries(layerProgress) as [id, layer] (id)} - {@const phase = hasReachedComplete ? 'complete' : getLayerPhase(layer.status)} - {@const layerPercent = - phase === 'complete' ? 100 : layer.total > 0 ? Math.round((layer.current / layer.total) * 100) : 0} -
-
- {id.slice(0, 12)} - - {#if phase === 'complete'} - ✓ - {:else if layer.total > 0} - {layerPercent}% - {:else} - {layer.status} - {/if} - -
- -
- {/each} -
-
-
- {/if} - - {#if isPulling} -

{m.images_pull_wait_message()}

- {/if} - {/if} -
- {:else} -
- - - - {/if} +
+ + + {/snippet} {#snippet footer()} - {#if pullError} -
- resetState()} - customLabel={m.common_retry()} - /> - (open = false)} customLabel={m.common_close()} /> -
- {:else if !showPullUI} -
- (open = false)} /> - -
- {/if} +
+ (open = false)} /> + +
{/snippet}
diff --git a/frontend/src/lib/components/sidebar/sidebar.svelte b/frontend/src/lib/components/sidebar/sidebar.svelte index b7d12a05f4..4cb9c36021 100644 --- a/frontend/src/lib/components/sidebar/sidebar.svelte +++ b/frontend/src/lib/components/sidebar/sidebar.svelte @@ -20,6 +20,7 @@ import SidebarLogo from './sidebar-logo.svelte'; import SidebarUpdatebanner from './sidebar-updatebanner.svelte'; import SidebarPinButton from './sidebar-pin-button.svelte'; + import ActivityCenterTrigger from '$lib/components/activity/activity-center-trigger.svelte'; import userStore from '$lib/stores/user-store'; import settingsStore from '$lib/stores/config-store'; import { m } from '$lib/paraglide/messages'; @@ -99,6 +100,17 @@ {:else} (envSwitcherOpen = true)} /> {/if} + {#if isCollapsed} +
+ +
+ {:else} + + + + + + {/if} {#if managementItems.length > 0} diff --git a/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte b/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte index 75a8acf6bf..b989326578 100644 --- a/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte +++ b/frontend/src/lib/components/vulnerability/vulnerability-scan-item.svelte @@ -9,6 +9,7 @@ import { formatTime } from '$lib/utils/formatting'; import { queryKeys } from '$lib/query/query-keys'; import { vulnerabilityService } from '$lib/services/vulnerability-service'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { startVulnerabilityScanPolling, stabilizeFailedVulnerabilitySummary, @@ -45,9 +46,9 @@ const isScanInProgress = $derived(isScanning || isVulnerabilityScanInProgress(scanSummary?.status)); const scanPhaseSteps = [ - { key: 'creating_container', label: 'Creating container' }, - { key: 'scanning_image', label: 'Scanning image' }, - { key: 'storing_results', label: 'Storing results' } + { key: 'creating_container', label: m.activity_scan_phase_creating_container() }, + { key: 'scanning_image', label: m.activity_scan_phase_scanning_image() }, + { key: 'storing_results', label: m.activity_scan_phase_storing_results() } ] as const; const scanErrorMessage = $derived.by(() => { const detail = scanSummary?.error?.trim(); @@ -134,6 +135,7 @@ imageId: result.imageId, scanTime: result.scanTime, status: result.status, + activityId: result.activityId, scanPhase: result.scanPhase, summary: result.summary, error: result.error @@ -141,10 +143,11 @@ scanSummary = summary; onScanned?.(summary); if (result.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), activityToastOptions(result.activityId)); } else if (result.status === 'failed') { - toast.error(result.error || m.vuln_scan_failed()); + toast.error(result.error || m.vuln_scan_failed(), activityToastOptions(result.activityId)); } else if (pollingEnabled) { + toast.info(m.vuln_scan_started(), activityToastOptions(result.activityId)); beginPolling(true); } } @@ -207,10 +210,11 @@ stopScanPolling(); if (showToast) { + const toastOptions = activityToastOptions(extractActivityId(resolvedSummary)); if (resolvedSummary.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), toastOptions); } else { - toast.error(resolvedSummary.error || m.vuln_scan_failed()); + toast.error(resolvedSummary.error || m.vuln_scan_failed(), toastOptions); } } }, diff --git a/frontend/src/lib/config/navigation-config.ts b/frontend/src/lib/config/navigation-config.ts index d0e664976a..f2bf2478a9 100644 --- a/frontend/src/lib/config/navigation-config.ts +++ b/frontend/src/lib/config/navigation-config.ts @@ -25,7 +25,8 @@ import { TemplateIcon, GlobeIcon, UpdateIcon, - VariableIcon + VariableIcon, + ActivityIcon } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import type { ShortcutKey } from '$lib/utils/navigation'; @@ -258,6 +259,13 @@ export const navigationItems: NavigationSections = { scope: 'env', requiredPermission: 'notifications:manage' }, + { + title: m.activity_settings_title(), + url: '/settings/activity', + icon: ActivityIcon, + scope: 'env', + requiredPermission: 'settings:read' + }, { title: m.builds(), url: '/settings/builds', diff --git a/frontend/src/lib/icons/index.ts b/frontend/src/lib/icons/index.ts index 2f1963bd1b..d651c81d98 100644 --- a/frontend/src/lib/icons/index.ts +++ b/frontend/src/lib/icons/index.ts @@ -12,6 +12,7 @@ export { default as ImagesIcon } from 'virtual:icons/solar/gallery-linear'; export { default as NetworksIcon } from 'virtual:icons/fluent/virtual-network-16-filled'; export { default as VolumesIcon } from 'virtual:icons/fluent/hard-drive-20-filled'; export { default as EventsIcon } from 'virtual:icons/material-symbols/event-list'; +export { default as ActivityIcon } from 'virtual:icons/lucide/activity'; export { default as SettingsIcon } from 'virtual:icons/solar/settings-outline'; export { default as AppearanceIcon } from 'virtual:icons/f7/paintbrush'; export { default as DockerBrandIcon } from 'virtual:icons/cib/docker'; diff --git a/frontend/src/lib/services/activity-service.ts b/frontend/src/lib/services/activity-service.ts new file mode 100644 index 0000000000..45ec59d003 --- /dev/null +++ b/frontend/src/lib/services/activity-service.ts @@ -0,0 +1,60 @@ +import BaseAPIService, { handleUnauthorizedResponseInternal } from './api-service'; +import { environmentStore } from '$lib/stores/environment.store.svelte'; +import type { Activity, ActivityClearHistoryResult, ActivityDetail } from '$lib/types/activity.type'; +import type { Paginated, SearchPaginationSortRequest } from '$lib/types/shared'; +import { transformPaginationParams } from '$lib/utils/tables'; + +export class ActivityService extends BaseAPIService { + private async resolveEnvironmentId(environmentId?: string): Promise { + return environmentId ?? (await environmentStore.getCurrentEnvironmentId()); + } + + async getActivities(options?: SearchPaginationSortRequest, environmentId?: string): Promise> { + const envId = await this.resolveEnvironmentId(environmentId); + const params = transformPaginationParams(options); + const res = await this.api.get(`/environments/${envId}/activities`, { params }); + return res.data; + } + + async getActivity(activityId: string, environmentId?: string, limit = 500): Promise { + const envId = await this.resolveEnvironmentId(environmentId); + return this.handleResponse(this.api.get(`/environments/${envId}/activities/${activityId}`, { params: { limit } })); + } + + async clearHistory(environmentId?: string): Promise { + const envId = await this.resolveEnvironmentId(environmentId); + return this.handleResponse(this.api.delete(`/environments/${envId}/activities/history`)); + } + + getActivityStreamUrl(environmentId: string, limit = 50): string { + const baseUrl = this.api.defaults.baseURL.replace(/\/+$/, ''); + const params = new URLSearchParams({ limit: String(limit) }); + return `${baseUrl}/environments/${encodeURIComponent(environmentId)}/activities/stream?${params.toString()}`; + } + + async openActivityStream(environmentId: string, signal: AbortSignal, limit = 50, retry = false): Promise { + const response = await fetch(this.getActivityStreamUrl(environmentId, limit), { + credentials: 'include', + headers: { Accept: 'application/x-json-stream' }, + signal + }); + if (response.status === 401) { + const action = await handleUnauthorizedResponseInternal( + `/environments/${encodeURIComponent(environmentId)}/activities/stream`, + retry + ); + if (action === 'retry') { + return this.openActivityStream(environmentId, signal, limit, true); + } + if (action === 'redirect') { + return new Promise(() => {}); + } + } + if (!response.ok) { + throw new Error(`Activity stream failed with status ${response.status}`); + } + return response; + } +} + +export const activityService = new ActivityService(); diff --git a/frontend/src/lib/services/api-service.ts b/frontend/src/lib/services/api-service.ts index 598932076a..4452b30b5d 100644 --- a/frontend/src/lib/services/api-service.ts +++ b/frontend/src/lib/services/api-service.ts @@ -161,6 +161,66 @@ function getRequestPath(url: string, baseURL: string): string { } let tokenRefreshHandler: (() => Promise) | null = null; +const skipAuthPathsInternal = [ + '/auth/login', + '/auth/logout', + '/auth/refresh', + '/auth/oidc', + '/auth/oidc/login', + '/auth/oidc/callback', + '/auth/auto-login', + '/auth/auto-login-config', + '/settings/public' +]; + +type UnauthorizedActionInternal = 'none' | 'redirect' | 'retry'; + +function isAuthPagePathInternal(pathname: string): boolean { + return ( + pathname.startsWith('/login') || + pathname.startsWith('/logout') || + pathname.startsWith('/oidc') || + pathname.startsWith('/auth/oidc') + ); +} + +export async function handleUnauthorizedResponseInternal( + requestPath: string, + retry = false, + serverMsg?: string | null +): Promise { + if (typeof window === 'undefined' || retry) { + return 'none'; + } + + const isVersionMismatch = serverMsg?.toLowerCase().includes('application has been updated'); + const isAuthApi = skipAuthPathsInternal.some((path) => requestPath.startsWith(path)); + const pathname = window.location.pathname || '/'; + const isOnAuthPage = isAuthPagePathInternal(pathname); + + if (!isAuthApi && !isOnAuthPage && tokenRefreshHandler) { + try { + await tokenRefreshHandler(); + return 'retry'; + } catch { + if (isVersionMismatch) { + toast.info('Application has been updated. Please log in again.'); + } + const redirectTo = encodeURIComponent(pathname); + window.location.replace(`/login?redirect=${redirectTo}`); + return 'redirect'; + } + } + + if (!isAuthApi && !isOnAuthPage && isVersionMismatch) { + toast.info('Application has been updated. Please log in again.'); + const redirectTo = encodeURIComponent(pathname); + window.location.replace(`/login?redirect=${redirectTo}`); + return 'redirect'; + } + + return 'none'; +} class APIClient { defaults: { baseURL: string }; @@ -232,49 +292,18 @@ class APIClient { }; if (errorResponse.status === 401 && typeof window !== 'undefined' && !config._retry) { - const serverMsg = extractServerMessage(parsed); - const isVersionMismatch = serverMsg?.toLowerCase().includes('application has been updated'); - const reqUrl = getRequestPath(url, baseURL); - const skipAuthPaths = [ - '/auth/login', - '/auth/logout', - '/auth/refresh', - '/auth/oidc', - '/auth/oidc/login', - '/auth/oidc/callback', - '/auth/auto-login', - '/auth/auto-login-config', - '/settings/public' - ]; - const isAuthApi = skipAuthPaths.some((path) => reqUrl.startsWith(path)); - const pathname = window.location.pathname || '/'; - const isOnAuthPage = - pathname.startsWith('/login') || - pathname.startsWith('/logout') || - pathname.startsWith('/oidc') || - pathname.startsWith('/auth/oidc'); - - if (!isAuthApi && !isOnAuthPage && tokenRefreshHandler) { - try { - await tokenRefreshHandler(); - return this.performRequest(method, url, data, { - ...config, - _retry: true - }); - } catch { - if (isVersionMismatch) { - toast.info('Application has been updated. Please log in again.'); - } - const redirectTo = encodeURIComponent(pathname); - window.location.replace(`/login?redirect=${redirectTo}`); - return new Promise(() => {}); - } + const action = await handleUnauthorizedResponseInternal( + getRequestPath(url, baseURL), + !!config._retry, + extractServerMessage(parsed) + ); + if (action === 'retry') { + return this.performRequest(method, url, data, { + ...config, + _retry: true + }); } - - if (!isAuthApi && !isOnAuthPage && isVersionMismatch) { - toast.info('Application has been updated. Please log in again.'); - const redirectTo = encodeURIComponent(pathname); - window.location.replace(`/login?redirect=${redirectTo}`); + if (action === 'redirect') { return new Promise(() => {}); } } diff --git a/frontend/src/lib/services/image-service.ts b/frontend/src/lib/services/image-service.ts index 7dd45303c8..f62be729dd 100644 --- a/frontend/src/lib/services/image-service.ts +++ b/frontend/src/lib/services/image-service.ts @@ -49,9 +49,9 @@ export class ImageService extends BaseAPIService { return this.handleResponse(this.api.post(`/environments/${envId}/images/pull`, { imageName, tag, auth })); } - async deleteImage(imageId: string, options?: { force?: boolean; noprune?: boolean }): Promise { + async deleteImage(imageId: string, options?: { force?: boolean; noprune?: boolean }): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); - await this.handleResponse(this.api.delete(`/environments/${envId}/images/${imageId}`, { params: options })); + return this.handleResponse(this.api.delete(`/environments/${envId}/images/${imageId}`, { params: options })); } async pruneImages(options: PruneImagesOptions): Promise { diff --git a/frontend/src/lib/services/volume-backup-service.ts b/frontend/src/lib/services/volume-backup-service.ts index 15b89acff7..b29ca5803e 100644 --- a/frontend/src/lib/services/volume-backup-service.ts +++ b/frontend/src/lib/services/volume-backup-service.ts @@ -20,12 +20,12 @@ export class VolumeBackupService extends BaseAPIService { return res.data; } - async restoreBackup(volumeName: string, backupId: string): Promise { + async restoreBackup(volumeName: string, backupId: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse(this.api.post(`/environments/${envId}/volumes/${volumeName}/backups/${backupId}/restore`)); } - async restoreBackupFiles(volumeName: string, backupId: string, paths: string[]): Promise { + async restoreBackupFiles(volumeName: string, backupId: string, paths: string[]): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse( this.api.post(`/environments/${envId}/volumes/${volumeName}/backups/${backupId}/restore-files`, { @@ -48,7 +48,7 @@ export class VolumeBackupService extends BaseAPIService { return res.data.data ?? []; } - async deleteBackup(backupId: string): Promise { + async deleteBackup(backupId: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse(this.api.delete(`/environments/${envId}/volumes/backups/${backupId}`)); } @@ -68,7 +68,7 @@ export class VolumeBackupService extends BaseAPIService { link.remove(); } - async uploadAndRestore(volumeName: string, file: File): Promise { + async uploadAndRestore(volumeName: string, file: File): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); const formData = new FormData(); formData.append('file', file); diff --git a/frontend/src/lib/services/volume-browser-service.ts b/frontend/src/lib/services/volume-browser-service.ts index 29a35b93ba..fdc94a1ebf 100644 --- a/frontend/src/lib/services/volume-browser-service.ts +++ b/frontend/src/lib/services/volume-browser-service.ts @@ -37,7 +37,7 @@ export class VolumeBrowserService extends BaseAPIService { link.remove(); } - async uploadFile(volumeName: string, path: string, file: File): Promise { + async uploadFile(volumeName: string, path: string, file: File): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); const formData = new FormData(); formData.append('file', file); @@ -48,7 +48,7 @@ export class VolumeBrowserService extends BaseAPIService { ); } - async createDirectory(volumeName: string, path: string): Promise { + async createDirectory(volumeName: string, path: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse( this.api.post(`/environments/${envId}/volumes/${volumeName}/browse/mkdir`, null, { @@ -57,7 +57,7 @@ export class VolumeBrowserService extends BaseAPIService { ); } - async deleteFile(volumeName: string, path: string): Promise { + async deleteFile(volumeName: string, path: string): Promise { const envId = await environmentStore.getCurrentEnvironmentId(); return this.handleResponse( this.api.delete(`/environments/${envId}/volumes/${volumeName}/browse`, { diff --git a/frontend/src/lib/stores/activity.store.svelte.ts b/frontend/src/lib/stores/activity.store.svelte.ts new file mode 100644 index 0000000000..a764f01c82 --- /dev/null +++ b/frontend/src/lib/stores/activity.store.svelte.ts @@ -0,0 +1,435 @@ +import { browser } from '$app/environment'; +import { activityService } from '$lib/services/activity-service'; +import { environmentStore, LOCAL_DOCKER_ENVIRONMENT_ID } from '$lib/stores/environment.store.svelte'; +import type { + Activity, + ActivityDetail, + ActivityFilter, + ActivityMessage, + ActivityStatus, + ActivityStreamEvent +} from '$lib/types/activity.type'; + +const ACTIVITY_LIST_LIMIT = 50; +const ACTIVITY_DETAIL_LIMIT = 500; +const MAX_RECONNECT_DELAY = 15_000; +const MAX_RECONNECT_ATTEMPTS = 20; + +function sortActivitiesInternal(items: Activity[]): Activity[] { + return [...items].sort((a, b) => { + const aActive = isActiveStatusInternal(a.status); + const bActive = isActiveStatusInternal(b.status); + if (aActive !== bActive) return aActive ? -1 : 1; + return getActivitySortTimeInternal(b) - getActivitySortTimeInternal(a); + }); +} + +function getActivitySortTimeInternal(activity: Activity): number { + const value = activity.updatedAt || activity.endedAt || activity.startedAt || activity.createdAt; + return value ? new Date(value).getTime() : 0; +} + +function isActiveStatusInternal(status: ActivityStatus): boolean { + return status === 'queued' || status === 'running'; +} + +function filterActivityInternal(activity: Activity, filter: ActivityFilter): boolean { + switch (filter) { + case 'running': + return isActiveStatusInternal(activity.status); + case 'failed': + return activity.status === 'failed'; + case 'completed': + return activity.status === 'success' || activity.status === 'cancelled'; + } +} + +function createActivityStore() { + let _activities = $state([]); + let _details = $state>({}); + let _expandedActivityIds = $state>({}); + let _detailLoadingIds = $state>({}); + let _detailErrorIds = $state>({}); + let _filter = $state('running'); + let _open = $state(false); + let _loading = $state(false); + let _connected = $state(false); + let _streamError = $state(false); + let _currentEnvironmentId = $state(LOCAL_DOCKER_ENVIRONMENT_ID); + + let started = false; + let streamGeneration = 0; + let streamAbortController: AbortController | null = null; + let reconnectTimer: ReturnType | null = null; + let unsubscribeEnvironment: (() => void) | null = null; + let reconnectAttempt = 0; + + function clearReconnectTimerInternal() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function abortStreamInternal() { + clearReconnectTimerInternal(); + streamAbortController?.abort(); + streamAbortController = null; + _connected = false; + } + + function resetEnvironmentStateInternal(environmentId: string) { + _currentEnvironmentId = environmentId || LOCAL_DOCKER_ENVIRONMENT_ID; + _activities = []; + _details = {}; + _expandedActivityIds = {}; + _detailLoadingIds = {}; + _detailErrorIds = {}; + _connected = false; + _streamError = false; + _loading = true; + reconnectAttempt = 0; + } + + async function refreshInternal(environmentId = _currentEnvironmentId, generation = streamGeneration) { + _loading = true; + try { + const result = await activityService.getActivities({ pagination: { page: 1, limit: ACTIVITY_LIST_LIMIT } }, environmentId); + if (generation !== streamGeneration || environmentId !== _currentEnvironmentId) { + return; + } + replaceSnapshotInternal(result.data ?? []); + } catch (error) { + console.warn('Failed to refresh activities:', error); + } finally { + if (generation === streamGeneration && environmentId === _currentEnvironmentId) { + _loading = false; + } + } + } + + function replaceSnapshotInternal(activities: Activity[]) { + _activities = sortActivitiesInternal(activities); + // Drop expansion state for activities that no longer exist in the snapshot. + const present = new Set(_activities.map((activity) => activity.id)); + const nextExpanded: Record = {}; + for (const id of Object.keys(_expandedActivityIds)) { + if (_expandedActivityIds[id] && present.has(id)) { + nextExpanded[id] = true; + } + } + _expandedActivityIds = nextExpanded; + } + + function mergeActivityInternal(activity: Activity) { + const index = _activities.findIndex((item) => item.id === activity.id); + if (index >= 0) { + _activities = sortActivitiesInternal([..._activities.slice(0, index), activity, ..._activities.slice(index + 1)]); + } else { + _activities = sortActivitiesInternal([activity, ..._activities]).slice(0, ACTIVITY_LIST_LIMIT); + } + + const existingDetail = _details[activity.id]; + if (existingDetail) { + _details = { + ..._details, + [activity.id]: { + ...existingDetail, + activity + } + }; + } + } + + function mergeMessageInternal(message: ActivityMessage) { + const detail = _details[message.activityId]; + if (!detail) { + return; + } + + const exists = detail.messages.some((item) => item.id === message.id); + const messages = exists ? detail.messages : [...detail.messages, message].slice(-ACTIVITY_DETAIL_LIMIT); + _details = { + ..._details, + [message.activityId]: { + ...detail, + messages + } + }; + } + + function applyStreamEventInternal(event: ActivityStreamEvent) { + switch (event.type) { + case 'snapshot': + replaceSnapshotInternal(event.activities ?? []); + _loading = false; + break; + case 'activity': + if (event.activity) { + mergeActivityInternal(event.activity); + } + break; + case 'message': + if (event.message) { + mergeMessageInternal(event.message); + } + break; + case 'heartbeat': + _connected = true; + break; + } + } + + async function connectStreamInternal(environmentId: string, generation: number) { + if (!browser || generation !== streamGeneration) { + return; + } + + streamAbortController = new AbortController(); + try { + const response = await activityService.openActivityStream(environmentId, streamAbortController.signal, ACTIVITY_LIST_LIMIT); + if (generation !== streamGeneration || !response.body) { + streamAbortController = null; + return; + } + + _connected = true; + _streamError = false; + reconnectAttempt = 0; + await readJSONLinesInternal(response.body, generation); + } catch (error) { + if (!streamAbortController?.signal.aborted && generation === streamGeneration) { + console.warn('Activity stream disconnected:', error); + } + } finally { + streamAbortController = null; + if (generation === streamGeneration) { + _connected = false; + scheduleReconnectInternal(environmentId, generation); + } + } + } + + async function readJSONLinesInternal(stream: ReadableStream, generation: number) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (generation === streamGeneration) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + handleStreamLineInternal(line); + } + } + + buffer += decoder.decode(); + if (buffer.trim()) { + handleStreamLineInternal(buffer); + } + } finally { + reader.releaseLock(); + } + } + + function handleStreamLineInternal(line: string) { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + try { + applyStreamEventInternal(JSON.parse(trimmed) as ActivityStreamEvent); + } catch (error) { + console.warn('Failed to parse activity stream line:', error); + } + } + + function scheduleReconnectInternal(environmentId: string, generation: number) { + if (!browser || !started || generation !== streamGeneration) { + return; + } + + if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { + _streamError = true; + return; + } + + clearReconnectTimerInternal(); + const delay = Math.min(1000 * 2 ** reconnectAttempt, MAX_RECONNECT_DELAY); + reconnectAttempt += 1; + reconnectTimer = setTimeout(() => { + void connectStreamInternal(environmentId, generation); + }, delay); + } + + function restartForEnvironmentInternal(environmentId: string) { + streamGeneration += 1; + abortStreamInternal(); + resetEnvironmentStateInternal(environmentId); + const generation = streamGeneration; + void refreshInternal(environmentId, generation); + void connectStreamInternal(environmentId, generation); + } + + async function loadDetailInternal(activityId: string) { + if (_details[activityId] || _detailLoadingIds[activityId]) { + return; + } + + _detailLoadingIds = { ..._detailLoadingIds, [activityId]: true }; + try { + const detail = await activityService.getActivity(activityId, _currentEnvironmentId, ACTIVITY_DETAIL_LIMIT); + _details = { ..._details, [activityId]: detail }; + const nextErrors = { ..._detailErrorIds }; + delete nextErrors[activityId]; + _detailErrorIds = nextErrors; + } catch (error) { + console.warn('Failed to load activity detail:', error); + _detailErrorIds = { ..._detailErrorIds, [activityId]: true }; + } finally { + const next = { ..._detailLoadingIds }; + delete next[activityId]; + _detailLoadingIds = next; + } + } + + function setActivityExpanded(activityId: string, expanded: boolean) { + if (!activityId) { + return; + } + + if (expanded) { + if (_expandedActivityIds[activityId]) { + return; + } + _expandedActivityIds = { ..._expandedActivityIds, [activityId]: true }; + void loadDetailInternal(activityId); + } else { + if (!_expandedActivityIds[activityId]) { + return; + } + const next = { ..._expandedActivityIds }; + delete next[activityId]; + _expandedActivityIds = next; + } + } + + function toggleActivity(activityId: string) { + setActivityExpanded(activityId, !_expandedActivityIds[activityId]); + } + + return { + get activities(): Activity[] { + return _activities; + }, + get filteredActivities(): Activity[] { + return _activities.filter((activity) => filterActivityInternal(activity, _filter)); + }, + get activeCount(): number { + return _activities.filter((activity) => isActiveStatusInternal(activity.status)).length; + }, + get filter(): ActivityFilter { + return _filter; + }, + get open(): boolean { + return _open; + }, + get loading(): boolean { + return _loading; + }, + get connected(): boolean { + return _connected; + }, + get streamError(): boolean { + return _streamError; + }, + get currentEnvironmentId(): string { + return _currentEnvironmentId; + }, + isExpanded(activityId: string): boolean { + return !!_expandedActivityIds[activityId]; + }, + isDetailLoading(activityId: string): boolean { + return !!_detailLoadingIds[activityId]; + }, + isDetailError(activityId: string): boolean { + return !!_detailErrorIds[activityId]; + }, + getDetail(activityId: string): ActivityDetail | null { + const activity = _details[activityId]?.activity ?? _activities.find((item) => item.id === activityId); + if (!activity) { + return null; + } + return _details[activityId] ?? { activity, messages: [] }; + }, + getActivity(activityId: string): Activity | null { + return _details[activityId]?.activity ?? _activities.find((item) => item.id === activityId) ?? null; + }, + start: async () => { + if (!browser || started) { + return; + } + + started = true; + await environmentStore.ready; + restartForEnvironmentInternal(environmentStore.selected?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID); + unsubscribeEnvironment = environmentStore.subscribeSelected((environment) => { + restartForEnvironmentInternal(environment?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID); + }); + }, + stop: () => { + started = false; + unsubscribeEnvironment?.(); + unsubscribeEnvironment = null; + streamGeneration += 1; + abortStreamInternal(); + }, + refresh: () => refreshInternal(), + clearHistory: async () => { + await activityService.clearHistory(_currentEnvironmentId); + _details = {}; + _expandedActivityIds = {}; + _detailLoadingIds = {}; + _detailErrorIds = {}; + await refreshInternal(); + }, + setFilter: (filter: ActivityFilter) => { + _filter = filter; + }, + setOpen: (open: boolean) => { + _open = open; + }, + openCenter: (activityId?: string) => { + _open = true; + if (activityId) { + setActivityExpanded(activityId, true); + } + }, + retryLoadDetail: (activityId: string) => { + const nextErrors = { ..._detailErrorIds }; + delete nextErrors[activityId]; + _detailErrorIds = nextErrors; + const nextDetails = { ..._details }; + delete nextDetails[activityId]; + _details = nextDetails; + void loadDetailInternal(activityId); + }, + retryStream: () => { + _streamError = false; + reconnectAttempt = 0; + restartForEnvironmentInternal(_currentEnvironmentId); + }, + setActivityExpanded, + toggleActivity + }; +} + +export const activityStore = createActivityStore(); diff --git a/frontend/src/lib/types/activity.type.ts b/frontend/src/lib/types/activity.type.ts new file mode 100644 index 0000000000..8c2a8a571d --- /dev/null +++ b/frontend/src/lib/types/activity.type.ts @@ -0,0 +1,82 @@ +export type ActivityStatus = 'queued' | 'running' | 'success' | 'failed' | 'cancelled'; + +export type ActivityType = + | 'image_pull' + | 'image_build' + | 'image_update_check' + | 'project_pull' + | 'project_build' + | 'project_deploy' + | 'project_redeploy' + | 'project_down' + | 'project_restart' + | 'project_destroy' + | 'container_start' + | 'container_stop' + | 'container_restart' + | 'container_redeploy' + | 'container_delete' + | 'vulnerability_scan' + | 'auto_update' + | 'system_prune' + | 'resource_action'; + +export type ActivityMessageLevel = 'info' | 'warning' | 'error' | 'success'; + +export type ActivityFilter = 'running' | 'failed' | 'completed'; + +export interface Activity { + id: string; + environmentId: string; + sourceEnvironmentId?: string; + sourceEnvironmentName?: string; + type: ActivityType; + status: ActivityStatus; + resourceType?: string; + resourceId?: string; + resourceName?: string; + progress?: number | null; + step?: string; + latestMessage?: string; + startedBy?: ActivityStartedBy; + startedAt: string; + endedAt?: string; + durationMs?: number; + error?: string; + metadata?: Record; + createdAt: string; + updatedAt?: string; +} + +export interface ActivityStartedBy { + userId?: string; + username: string; + displayName?: string; +} + +export interface ActivityMessage { + id: string; + activityId: string; + level: ActivityMessageLevel; + message: string; + payload?: Record; + createdAt: string; +} + +export interface ActivityDetail { + activity: Activity; + messages: ActivityMessage[]; +} + +export interface ActivityClearHistoryResult { + deleted: number; +} + +export interface ActivityStreamEvent { + type: 'snapshot' | 'activity' | 'message' | 'heartbeat'; + activityId?: string; + activity?: Activity; + activities?: Activity[]; + message?: ActivityMessage; + timestamp: string; +} diff --git a/frontend/src/lib/types/automation.ts b/frontend/src/lib/types/automation.ts index bca9bca7de..37ccc4c537 100644 --- a/frontend/src/lib/types/automation.ts +++ b/frontend/src/lib/types/automation.ts @@ -17,6 +17,7 @@ export interface AutoUpdateResult { failed: number; items: AutoUpdateResourceResult[]; duration: string; + activityId?: string; } export interface AutoUpdateResourceResult { diff --git a/frontend/src/lib/types/docker.ts b/frontend/src/lib/types/docker.ts index f3bcf3b80c..60642bda00 100644 --- a/frontend/src/lib/types/docker.ts +++ b/frontend/src/lib/types/docker.ts @@ -341,6 +341,7 @@ export interface ImageUpdateInfoDto { authUsername?: string; authRegistry?: string; usedCredential?: boolean; + activityId?: string; } export interface ImageUsageCounts { @@ -623,6 +624,7 @@ export interface VolumeSummaryDto { inUse: boolean; usageData?: VolumeUsageData; size: number; + activityId?: string; } export interface VolumeDetailDto extends VolumeSummaryDto { diff --git a/frontend/src/lib/types/environment.ts b/frontend/src/lib/types/environment.ts index def2279d4c..63eb4085af 100644 --- a/frontend/src/lib/types/environment.ts +++ b/frontend/src/lib/types/environment.ts @@ -158,6 +158,7 @@ export interface VulnerabilityScanResult { imageName: string; scanTime: string; status: VulnerabilityScanStatus; + activityId?: string; scanPhase?: 'creating_container' | 'scanning_image' | 'storing_results'; summary?: SeveritySummary; vulnerabilities?: Vulnerability[]; @@ -170,6 +171,7 @@ export interface VulnerabilityScanSummary { imageId: string; scanTime: string; status: VulnerabilityScanStatus; + activityId?: string; scanPhase?: 'creating_container' | 'scanning_image' | 'storing_results'; summary?: SeveritySummary; error?: string; diff --git a/frontend/src/lib/types/settings.ts b/frontend/src/lib/types/settings.ts index 5dda9d218e..86e16fb1a6 100644 --- a/frontend/src/lib/types/settings.ts +++ b/frontend/src/lib/types/settings.ts @@ -17,6 +17,8 @@ export type Settings = { pollingInterval: number; dockerClientRefreshInterval?: string; environmentHealthInterval: number; + activityHistoryRetentionDays: number; + activityHistoryMaxEntries: number; defaultDeployPullPolicy: 'missing' | 'always' | 'never'; scheduledPruneEnabled?: boolean; scheduledPruneInterval?: number; diff --git a/frontend/src/lib/types/swarm.ts b/frontend/src/lib/types/swarm.ts index 05f8ae2747..6a042093a7 100644 --- a/frontend/src/lib/types/swarm.ts +++ b/frontend/src/lib/types/swarm.ts @@ -536,6 +536,7 @@ export interface Project { gitRepositoryURL?: string; hasBuildDirective?: boolean; redeployDisabled?: boolean; + activityId?: string; updateInfo?: ProjectUpdateInfo; services?: ProjectService[]; runtimeServices?: RuntimeService[]; diff --git a/frontend/src/lib/utils/activity-toast.ts b/frontend/src/lib/utils/activity-toast.ts new file mode 100644 index 0000000000..fdf978fe94 --- /dev/null +++ b/frontend/src/lib/utils/activity-toast.ts @@ -0,0 +1,41 @@ +import { activityStore } from '$lib/stores/activity.store.svelte'; +import { m } from '$lib/paraglide/messages'; + +export function activityToastOptions(activityId?: string) { + if (!activityId) { + return undefined; + } + + return { + action: { + label: m.activity_view_activity(), + onClick: () => activityStore.openCenter(activityId) + } + }; +} + +export function extractActivityId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const activityId = (value as { activityId?: unknown }).activityId; + if (typeof activityId === 'string' && activityId.trim()) { + return activityId; + } + + if (Array.isArray(value)) { + for (const item of value) { + const nested = extractActivityId(item); + if (nested) return nested; + } + return undefined; + } + + for (const item of Object.values(value)) { + const nested = extractActivityId(item); + if (nested) return nested; + } + + return undefined; +} diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 585f64c582..75f85858d4 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -5,6 +5,7 @@ import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import AppSidebar from '$lib/components/sidebar/sidebar.svelte'; import MobileNav from '$lib/components/mobile-nav/mobile-nav.svelte'; + import ActivityCenter from '$lib/components/activity/activity-center.svelte'; import { IsMobile } from '$lib/hooks/is-mobile.svelte.js'; import { IsTablet } from '$lib/hooks/is-tablet.svelte.js'; import { getEffectiveNavigationSettings, navigationSettingsOverridesStore } from '$lib/utils/navigation'; @@ -105,3 +106,5 @@ {/if} + + diff --git a/frontend/src/routes/(app)/containers/+page.svelte b/frontend/src/routes/(app)/containers/+page.svelte index 0062eb8873..e69da33093 100644 --- a/frontend/src/routes/(app)/containers/+page.svelte +++ b/frontend/src/routes/(app)/containers/+page.svelte @@ -16,6 +16,7 @@ import type { SearchPaginationSortRequest } from '$lib/types/shared'; import type { ContainerListRequestOptions } from '$lib/services/container-service'; import ContainerEnvironmentSync from './components/container-environment-sync.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -55,8 +56,8 @@ const checkUpdatesMutation = createMutation(() => ({ mutationKey: queryKeys.containers.checkUpdates(envId), mutationFn: () => imageService.runAutoUpdate(), - onSuccess: async () => { - toast.success(m.containers_check_updates_success()); + onSuccess: async (data) => { + toast.success(m.containers_check_updates_success(), activityToastOptions(extractActivityId(data))); await refreshContainers(); }, onError: () => { diff --git a/frontend/src/routes/(app)/containers/container-table.actions.ts b/frontend/src/routes/(app)/containers/container-table.actions.ts index dfe95fcd09..6c40b304fb 100644 --- a/frontend/src/routes/(app)/containers/container-table.actions.ts +++ b/frontend/src/routes/(app)/containers/container-table.actions.ts @@ -4,6 +4,7 @@ import { containerService, type ContainersPaginatedResponse } from '$lib/service import type { ContainerSummaryDto } from '$lib/types/docker'; import { handleApiResultWithCallbacks } from '$lib/utils/api'; import { tryCatch } from '$lib/utils/api'; +import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { toast } from 'svelte-sonner'; import { getContainerDisplayName, type ActionStatus } from './container-table.helpers'; @@ -94,8 +95,8 @@ export function createContainerActions({ setLoadingState: (value) => { actionStatus[id] = value ? config.status : ''; }, - async onSuccess() { - toast.success(config.success()); + async onSuccess(data) { + toast.success(config.success(), activityToastOptions(extractActivityId(data))); await reloadContainers(); } }); @@ -135,8 +136,8 @@ export function createContainerActions({ setLoadingState: (value) => { actionStatus[id] = value ? 'removing' : ''; }, - async onSuccess() { - toast.success(m.containers_remove_success()); + async onSuccess(data) { + toast.success(m.containers_remove_success(), activityToastOptions(extractActivityId(data))); await reloadContainers(); } }); @@ -157,19 +158,19 @@ export function createContainerActions({ action: async () => { actionStatus[container.id] = 'updating'; try { - toast.info(m.containers_update_pulling_image()); - const result = await containerService.updateContainer(container.id); + const toastOptions = activityToastOptions(extractActivityId(result)); if (result.failed > 0) { const failedItem = result.items?.find((item: { status?: string; error?: string }) => item.status === 'failed'); toast.error( - m.containers_update_failed({ name: containerName }) + (failedItem?.error ? `: ${failedItem.error}` : '') + m.containers_update_failed({ name: containerName }) + (failedItem?.error ? `: ${failedItem.error}` : ''), + toastOptions ); } else if (result.updated > 0) { - toast.success(m.containers_update_success({ name: containerName })); + toast.success(m.containers_update_success({ name: containerName }), toastOptions); } else { - toast.info(m.image_update_up_to_date_title()); + toast.info(m.image_update_up_to_date_title(), toastOptions); } await reloadContainers(); @@ -199,8 +200,8 @@ export function createContainerActions({ setLoadingState: (value) => { actionStatus[container.id] = value ? 'redeploying' : ''; }, - async onSuccess() { - toast.success(m.container_redeploy_success()); + async onSuccess(data) { + toast.success(m.container_redeploy_success(), activityToastOptions(extractActivityId(data))); await refreshContainers(); } }); diff --git a/frontend/src/routes/(app)/dashboard/+page.svelte b/frontend/src/routes/(app)/dashboard/+page.svelte index f333cb59ab..a507df3128 100644 --- a/frontend/src/routes/(app)/dashboard/+page.svelte +++ b/frontend/src/routes/(app)/dashboard/+page.svelte @@ -9,6 +9,7 @@ import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { handleApiResultWithCallbacks } from '$lib/utils/api'; import { tryCatch } from '$lib/utils/api'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import userStore from '$lib/stores/user-store'; @@ -365,8 +366,8 @@ result: await tryCatch(systemService.startAllStoppedContainers()), message: m.dashboard_start_all_failed(), setLoadingState: (value) => (isLoading.starting = value), - onSuccess: async () => { - toast.success(m.dashboard_start_all_success()); + onSuccess: async (data) => { + toast.success(m.dashboard_start_all_success(), activityToastOptions(extractActivityId(data))); await refreshData(); } }); @@ -385,8 +386,8 @@ result: await tryCatch(systemService.stopAllContainers()), message: m.dashboard_stop_all_failed(), setLoadingState: (value) => (isLoading.stopping = value), - onSuccess: async () => { - toast.success(m.dashboard_stop_all_success()); + onSuccess: async (data) => { + toast.success(m.dashboard_stop_all_success(), activityToastOptions(extractActivityId(data))); await refreshData(); } }); @@ -413,12 +414,12 @@ result: await tryCatch(systemService.pruneAll(pruneRequest)), message: m.dashboard_prune_failed({ types: typesString }), setLoadingState: (value) => (isLoading.pruning = value), - onSuccess: async () => { + onSuccess: async (data) => { isPruneDialogOpen = false; if (selectedTypes.length === 1) { - toast.success(m.dashboard_prune_success_one({ types: typesString })); + toast.success(m.dashboard_prune_success_one({ types: typesString }), activityToastOptions(extractActivityId(data))); } else { - toast.success(m.dashboard_prune_success_many({ types: typesString })); + toast.success(m.dashboard_prune_success_many({ types: typesString }), activityToastOptions(extractActivityId(data))); } await refreshData(); } diff --git a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte index b79a4ed5b5..fb678971e2 100644 --- a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte +++ b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte @@ -28,6 +28,7 @@ import { capitalizeFirstLetter } from '$lib/utils/formatting'; import { tryCatch } from '$lib/utils/api'; import { getEnvironmentStatusVariant, isEnvironmentOnline, resolveEnvironmentStatus } from '$lib/utils/docker'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { createStatsWebSocket, type ReconnectingWebSocket } from '$lib/utils/ws'; import { bytes } from '$lib/utils/formatting'; import { @@ -614,17 +615,17 @@ setLoadingState: (value) => { pruningEnvironmentId = value ? environmentId : null; }, - onSuccess: async () => { + onSuccess: async (data) => { isPruneDialogOpen = false; pruneEnvironment = null; + const toastOptions = { + ...(activityToastOptions(extractActivityId(data)) ?? {}), + description: targetEnvironment.environment.name + }; if (selectedTypes.length === 1) { - toast.success(m.dashboard_prune_success_one({ types: typesString }), { - description: targetEnvironment.environment.name - }); + toast.success(m.dashboard_prune_success_one({ types: typesString }), toastOptions); } else { - toast.success(m.dashboard_prune_success_many({ types: typesString }), { - description: targetEnvironment.environment.name - }); + toast.success(m.dashboard_prune_success_many({ types: typesString }), toastOptions); } await refreshOverview(); } diff --git a/frontend/src/routes/(app)/images/+page.svelte b/frontend/src/routes/(app)/images/+page.svelte index 8fc59db22e..8c8b9f7751 100644 --- a/frontend/src/routes/(app)/images/+page.svelte +++ b/frontend/src/routes/(app)/images/+page.svelte @@ -18,6 +18,7 @@ import { CloseIcon, VolumesIcon, LocalFolderComputerIcon } from '$lib/icons'; import { createMutation, createQuery } from '@tanstack/svelte-query'; import PruneModeCard from '$lib/components/prune/prune-mode-card.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -59,8 +60,8 @@ mode: imagePruneMode, ...(imagePruneMode === 'olderThan' ? { until: imagePruneUntil } : {}) }), - onSuccess: async () => { - toast.success(m.images_pruned_success()); + onSuccess: async (data) => { + toast.success(m.images_pruned_success(), activityToastOptions(extractActivityId(data))); await Promise.all([imagesQuery.refetch(), imageUsageCountsQuery.refetch()]); isConfirmPruneDialogOpen = false; }, @@ -72,8 +73,8 @@ const checkUpdatesMutation = createMutation(() => ({ mutationKey: ['images', 'check-updates', envId], mutationFn: () => imageService.checkAllImages(), - onSuccess: async () => { - toast.success(m.images_update_check_completed()); + onSuccess: async (data) => { + toast.success(m.images_update_check_completed(), activityToastOptions(extractActivityId(data))); await imagesQuery.refetch(); }, onError: () => { diff --git a/frontend/src/routes/(app)/images/[imageId]/+page.svelte b/frontend/src/routes/(app)/images/[imageId]/+page.svelte index 907ee64a67..3628523afc 100644 --- a/frontend/src/routes/(app)/images/[imageId]/+page.svelte +++ b/frontend/src/routes/(app)/images/[imageId]/+page.svelte @@ -13,6 +13,7 @@ import { m } from '$lib/paraglide/messages'; import { imageService } from '$lib/services/image-service.js'; import { vulnerabilityService } from '$lib/services/vulnerability-service.js'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { startVulnerabilityScanPolling, stabilizeFailedVulnerabilitySummary, @@ -72,10 +73,11 @@ vulnerabilityScan = result; lastScanRequestedAt = result.scanTime || new Date().toISOString(); if (result.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), activityToastOptions(result.activityId)); } else if (result.status === 'failed') { - toast.error(result.error || m.vuln_scan_failed()); + toast.error(result.error || m.vuln_scan_failed(), activityToastOptions(result.activityId)); } else { + toast.info(m.vuln_scan_started(), activityToastOptions(result.activityId)); beginScanPolling(true); } } catch (error) { @@ -151,10 +153,11 @@ } as VulnerabilityScanResult; } if (showToast) { + const toastOptions = activityToastOptions(extractActivityId(resolvedSummary)); if (resolvedSummary.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), toastOptions); } else { - toast.error(resolvedSummary.error || m.vuln_scan_failed()); + toast.error(resolvedSummary.error || m.vuln_scan_failed(), toastOptions); } } }, @@ -221,8 +224,8 @@ result: await tryCatch(imageService.deleteImage(id, { force })), message: m.images_remove_failed(), setLoadingState: (value) => (isLoading.removing = value), - onSuccess: async () => { - toast.success(m.images_remove_success()); + onSuccess: async (data) => { + toast.success(m.images_remove_success(), activityToastOptions(extractActivityId(data))); goto('/images'); } }); diff --git a/frontend/src/routes/(app)/images/image-table.svelte b/frontend/src/routes/(app)/images/image-table.svelte index d66442b7bf..32fe28f8e8 100644 --- a/frontend/src/routes/(app)/images/image-table.svelte +++ b/frontend/src/routes/(app)/images/image-table.svelte @@ -27,6 +27,7 @@ import { isLikelyStaleFailedSummary, isVulnerabilityScanInProgress } from '$lib/utils/docker'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { hasPermission } from '$lib/utils/auth'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { DownloadIcon, @@ -157,8 +158,8 @@ result, message: m.images_remove_failed(), setLoadingState: () => {}, - onSuccess: async () => { - toast.success(m.images_remove_success()); + onSuccess: async (data) => { + toast.success(m.images_remove_success(), activityToastOptions(extractActivityId(data))); await refreshImages(); } }); @@ -181,8 +182,8 @@ result, message: m.images_pull_failed(), setLoadingState: () => {}, - onSuccess: async () => { - toast.success(m.images_pull_success({ repoTag })); + onSuccess: async (data) => { + toast.success(m.images_pull_success({ repoTag }), activityToastOptions(extractActivityId(data))); await refreshImages(); } }); @@ -204,6 +205,7 @@ imageId: data.imageId, scanTime: data.scanTime, status: data.status, + activityId: data.activityId, scanPhase: data.scanPhase, summary: data.summary, error: data.error @@ -211,14 +213,15 @@ await handleVulnerabilityScanChanged(imageId, summary); if (data.status === 'completed') { - toast.success(m.vuln_scan_completed()); + toast.success(m.vuln_scan_completed(), activityToastOptions(data.activityId)); return; } if (data.status === 'failed') { - toast.error(data.error || m.vuln_scan_failed()); + toast.error(data.error || m.vuln_scan_failed(), activityToastOptions(data.activityId)); return; } + toast.info(m.vuln_scan_started(), activityToastOptions(data.activityId)); startBatchScanPolling(); } }); @@ -439,7 +442,7 @@ {#snippet TagCell({ item }: { item: ImageSummaryDto })} {#if item.repoTags && item.repoTags.length > 0 && item.repoTags[0] !== ':'}
- {#each item.repoTags.slice(0, 2) as repoTag} + {#each item.repoTags.slice(0, 2) as repoTag, tagIndex (`${repoTag}-${tagIndex}`)} {@const tag = repoTag.split(':').pop() || repoTag} {tag} {/each} @@ -477,7 +480,7 @@ {@const visibleUsage = hasOverflow ? item.usedBy.slice(0, maxVisible) : item.usedBy} {@const overflowUsage = hasOverflow ? item.usedBy.slice(maxVisible) : []}
- {#each visibleUsage as usage} + {#each visibleUsage as usage, usageIndex (`${usage.type}-${usage.id ?? usage.name}-${usageIndex}`)} {#if usage.type === 'project'} {#if usage.id} @@ -531,7 +534,7 @@
- {#each overflowUsage as usage} + {#each overflowUsage as usage, usageIndex (`${usage.type}-${usage.id ?? usage.name}-${usageIndex}`)} {#if usage.type === 'project'} {#if usage.id} diff --git a/frontend/src/routes/(app)/networks/+page.svelte b/frontend/src/routes/(app)/networks/+page.svelte index f4fa7b9881..4189199bc7 100644 --- a/frontend/src/routes/(app)/networks/+page.svelte +++ b/frontend/src/routes/(app)/networks/+page.svelte @@ -13,6 +13,7 @@ import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; import { createMutation, createQuery } from '@tanstack/svelte-query'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -33,8 +34,11 @@ mutationKey: ['networks', 'create', envId], mutationFn: ({ name, options }: { name: string; options: NetworkCreateOptions }) => networkService.createNetwork(name, options), - onSuccess: async (_data, variables) => { - toast.success(m.common_create_success({ resource: `${m.resource_network()} "${variables.name}"` })); + onSuccess: async (data, variables) => { + toast.success( + m.common_create_success({ resource: `${m.resource_network()} "${variables.name}"` }), + activityToastOptions(extractActivityId(data)) + ); await networksQuery.refetch(); isCreateDialogOpen = false; }, diff --git a/frontend/src/routes/(app)/networks/[networkId]/+page.svelte b/frontend/src/routes/(app)/networks/[networkId]/+page.svelte index ec9d922623..a5f9b85d9f 100644 --- a/frontend/src/routes/(app)/networks/[networkId]/+page.svelte +++ b/frontend/src/routes/(app)/networks/[networkId]/+page.svelte @@ -27,6 +27,7 @@ import { m } from '$lib/paraglide/messages'; import { networkService } from '$lib/services/network-service'; import { ResourceDetailLayout, type DetailAction } from '$lib/layouts'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data }: PageProps = $props(); let errorMessage = $state(''); @@ -87,8 +88,11 @@ result: await tryCatch(networkService.deleteNetwork(network.id)), message: m.networks_remove_failed({ name: network?.name ?? shortId }), setLoadingState: (value) => (isRemoving = value), - onSuccess: async () => { - toast.success(m.networks_remove_success({ name: network?.name ?? shortId })); + onSuccess: async (data) => { + toast.success( + m.networks_remove_success({ name: network?.name ?? shortId }), + activityToastOptions(extractActivityId(data)) + ); goto('/networks'); }, onError: (error) => { @@ -317,7 +321,7 @@
- {#each network.peers as peer} + {#each network.peers as peer (`${peer.Name ?? ''}:${peer.IP ?? ''}`)}
{peer.Name}
@@ -345,7 +349,7 @@
- {#each Object.entries(network.services) as [name, service]} + {#each Object.entries(network.services) as [name, service] (name)}
@@ -377,7 +381,7 @@ >{m.networks_service_ports_label()}:
- {#each service.Ports as port} + {#each service.Ports as port (port)} {port} diff --git a/frontend/src/routes/(app)/networks/network-table.svelte b/frontend/src/routes/(app)/networks/network-table.svelte index 77426dc745..fe58e506b8 100644 --- a/frontend/src/routes/(app)/networks/network-table.svelte +++ b/frontend/src/routes/(app)/networks/network-table.svelte @@ -17,6 +17,7 @@ import { m } from '$lib/paraglide/messages'; import { networkService } from '$lib/services/network-service'; import { NetworksIcon, GlobeIcon, InspectIcon, TrashIcon, EllipsisIcon } from '$lib/icons'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; type FieldVisibility = Record; @@ -67,8 +68,11 @@ result: await tryCatch(networkService.deleteNetwork(id)), message: m.common_delete_failed({ resource: `${m.resource_network()} "${safeName}"` }), setLoadingState: (value) => (isLoading.remove = value), - onSuccess: async () => { - toast.success(m.common_delete_success({ resource: `${m.resource_network()} "${safeName}"` })); + onSuccess: async (data) => { + toast.success( + m.common_delete_success({ resource: `${m.resource_network()} "${safeName}"` }), + activityToastOptions(extractActivityId(data)) + ); await refreshNetworks(); } }); @@ -113,7 +117,8 @@ toast.success( m.common_delete_success({ resource: `${m.resource_network()} "${network.name ?? m.common_unknown()}"` - }) + }), + activityToastOptions(extractActivityId(result.data)) ); } } diff --git a/frontend/src/routes/(app)/projects/+page.svelte b/frontend/src/routes/(app)/projects/+page.svelte index 8c7468f245..762edcc2b4 100644 --- a/frontend/src/routes/(app)/projects/+page.svelte +++ b/frontend/src/routes/(app)/projects/+page.svelte @@ -15,6 +15,7 @@ import { untrack } from 'svelte'; import { createMutation, createQuery } from '@tanstack/svelte-query'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -66,7 +67,7 @@ // map to narrow the redeploy to projects that actually have updates. // This avoids hitting every project (and its registry) when nothing has // changed, which is especially expensive on instances with many projects. - await imageService.checkAllImages(); + const imageCheckResults = await imageService.checkAllImages(); const images = await imageService.getImagesForEnvironment(envId, { pagination: { page: 1, limit: 10000 } }); const projectIdsWithUpdates = new Set(); @@ -80,7 +81,7 @@ } if (projectIdsWithUpdates.size === 0) { - return { updated: 0 }; + return { updated: 0, activityId: extractActivityId(imageCheckResults) }; } const allProjects = await projectService.getProjectsForEnvironment(envId, { pagination: { page: 1, limit: 1000 } }); @@ -100,13 +101,14 @@ throw new Error(`${failed.length} project(s) failed to update (${succeeded} succeeded)`); } - return { updated: results.length }; + return { updated: results.length, activityId: extractActivityId(imageCheckResults) }; }, onSuccess: async (result) => { + const toastOptions = activityToastOptions(result.activityId); if (result && result.updated === 0) { - toast.success(m.image_update_up_to_date_title()); + toast.success(m.image_update_up_to_date_title(), toastOptions); } else { - toast.success(m.compose_update_success()); + toast.success(m.compose_update_success(), toastOptions); } await Promise.all([projectsQuery.refetch(), projectStatusCountsQuery.refetch()]); }, diff --git a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte index ddab9d9fff..824fd3501e 100644 --- a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte +++ b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte @@ -41,6 +41,7 @@ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import ProjectUpdateItem from '$lib/components/project-update-item.svelte'; import IfPermitted from '$lib/components/if-permitted.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); let projectId = $derived(data.projectId); @@ -345,10 +346,11 @@ .find((result) => !!result?.error?.trim()) ?.error?.trim(); const hasErrors = !!firstError; + const toastOptions = activityToastOptions(extractActivityId(results)); if (hasErrors) { - toast.error(firstError || m.containers_check_updates_failed()); + toast.error(firstError || m.containers_check_updates_failed(), toastOptions); } else { - toast.success(m.images_update_check_completed()); + toast.success(m.images_update_check_completed(), toastOptions); } await Promise.all([ refreshProjectDetails(), @@ -438,7 +440,7 @@ }; rebaseEditorDraft(savedProject); await syncProjectQueries(savedProject); - toast.success(m.common_update_success({ resource: m.project() })); + toast.success(m.common_update_success({ resource: m.project() }), activityToastOptions(extractActivityId(savedProject))); } }); } diff --git a/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte b/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte index 45dcd43212..26cc979cde 100644 --- a/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte +++ b/frontend/src/routes/(app)/projects/components/ProjectContainersTable.svelte @@ -23,6 +23,7 @@ import * as ArcaneTooltip from '$lib/components/arcane-tooltip'; import IconImage from '$lib/components/icon-image.svelte'; import { getArcaneIconUrlFromLabels } from '$lib/utils/docker'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { StartIcon, StopIcon, @@ -130,8 +131,8 @@ setLoadingState: (value) => { actionStatus[id] = value ? statusMap[action] : ''; }, - async onSuccess() { - toast.success(messageMap[action].success); + async onSuccess(data) { + toast.success(messageMap[action].success, activityToastOptions(extractActivityId(data))); await onRefresh?.(); } }); @@ -164,8 +165,8 @@ setLoadingState: (value) => { actionStatus[id] = value ? 'removing' : ''; }, - async onSuccess() { - toast.success(m.containers_remove_success()); + async onSuccess(data) { + toast.success(m.containers_remove_success(), activityToastOptions(extractActivityId(data))); await onRefresh?.(); } }); diff --git a/frontend/src/routes/(app)/projects/new/+page.svelte b/frontend/src/routes/(app)/projects/new/+page.svelte index 6839557d33..2569efe1ad 100644 --- a/frontend/src/routes/(app)/projects/new/+page.svelte +++ b/frontend/src/routes/(app)/projects/new/+page.svelte @@ -28,6 +28,7 @@ import { hasPermission } from '$lib/utils/auth'; import IfPermitted from '$lib/components/if-permitted.svelte'; import { ComposeEditorSplit } from '$lib/components/compose'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); @@ -96,7 +97,10 @@ message: m.common_create_failed({ resource: `${m.resource_project()} "${name}"` }), setLoadingState: (value) => (saving = value), onSuccess: async (project) => { - toast.success(m.common_create_success({ resource: `${m.resource_project()} "${name}"` })); + toast.success( + m.common_create_success({ resource: `${m.resource_project()} "${name}"` }), + activityToastOptions(extractActivityId(project)) + ); goto(`/projects/${project.id}`, { invalidateAll: true }); } }); diff --git a/frontend/src/routes/(app)/projects/projects-table.actions.ts b/frontend/src/routes/(app)/projects/projects-table.actions.ts index 0b3c631b45..cb44f8da5a 100644 --- a/frontend/src/routes/(app)/projects/projects-table.actions.ts +++ b/frontend/src/routes/(app)/projects/projects-table.actions.ts @@ -6,6 +6,7 @@ import { projectService } from '$lib/services/project-service'; import type { SearchPaginationSortRequest } from '$lib/types/shared'; import { handleApiResultWithCallbacks } from '$lib/utils/api'; import { tryCatch } from '$lib/utils/api'; +import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; import { toast } from 'svelte-sonner'; import type { ActionStatus } from './projects-table.helpers'; @@ -123,8 +124,8 @@ export function createProjectActions({ setLoadingState: (value) => { actionStatus[id] = value ? config.status : ''; }, - onSuccess: async () => { - toast.success(config.success()); + onSuccess: async (data) => { + toast.success(config.success(), activityToastOptions(extractActivityId(data))); await refreshProjects(); } }); @@ -164,8 +165,8 @@ export function createProjectActions({ setLoadingState: (value) => { actionStatus[id] = value ? 'destroying' : ''; }, - onSuccess: async () => { - toast.success(m.compose_destroy_success()); + onSuccess: async (data) => { + toast.success(m.compose_destroy_success(), activityToastOptions(extractActivityId(data))); await refreshProjects(); } }); diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index 112f800b8f..df11fe8f57 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -15,7 +15,8 @@ CloseIcon, JobsIcon, CodeIcon, - GlobeIcon + GlobeIcon, + ActivityIcon } from '$lib/icons'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { Card } from '$lib/components/ui/card'; @@ -49,7 +50,8 @@ apikey: ApiKeyIcon, jobs: JobsIcon, code: CodeIcon, - globe: GlobeIcon + globe: GlobeIcon, + activity: ActivityIcon }; onMount(async () => { diff --git a/frontend/src/routes/(app)/settings/activity/+page.svelte b/frontend/src/routes/(app)/settings/activity/+page.svelte new file mode 100644 index 0000000000..60df08997e --- /dev/null +++ b/frontend/src/routes/(app)/settings/activity/+page.svelte @@ -0,0 +1,92 @@ + + + + {#snippet mainContent()} +
+
+

{m.activity_history_section_title()}

+
+
+
+
+ +

{m.activity_history_retention_days_description()}

+
+
+ +
+
+ +
+
+
+ +

{m.activity_history_max_entries_description()}

+
+
+ +
+
+
+
+
+
+
+ {/snippet} +
diff --git a/frontend/src/routes/(app)/settings/activity/+page.ts b/frontend/src/routes/(app)/settings/activity/+page.ts new file mode 100644 index 0000000000..41fcb0fd60 --- /dev/null +++ b/frontend/src/routes/(app)/settings/activity/+page.ts @@ -0,0 +1,20 @@ +import type { PageLoad } from './$types'; +import { settingsService } from '$lib/services/settings-service'; +import { environmentStore } from '$lib/stores/environment.store.svelte'; +import { queryKeys } from '$lib/query/query-keys'; + +export const load: PageLoad = async ({ parent }) => { + const { queryClient } = await parent(); + const envId = await environmentStore.getCurrentEnvironmentId(); + + try { + const settings = await queryClient.fetchQuery({ + queryKey: queryKeys.settings.byEnvironment(envId), + queryFn: () => settingsService.getSettingsForEnvironmentMerged(envId) + }); + return { settings }; + } catch (error) { + console.error('Failed to load activity settings:', error); + throw error; + } +}; diff --git a/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte b/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte index 39d88c243a..8cb4064864 100644 --- a/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte +++ b/frontend/src/routes/(app)/swarm/nodes/swarm-node-label-dialog.svelte @@ -48,7 +48,7 @@ >
- + {#if isReservedPrefix}

- Prefixes 'engine.labels' and 'com.docker.swarm' are reserved for system use. + {m.swarm_node_label_reserved_prefixes()}

{/if}
- +
+
{#snippet footer()} diff --git a/frontend/src/routes/(app)/volumes/+page.svelte b/frontend/src/routes/(app)/volumes/+page.svelte index 5db7c36bb6..0cf3ea7d59 100644 --- a/frontend/src/routes/(app)/volumes/+page.svelte +++ b/frontend/src/routes/(app)/volumes/+page.svelte @@ -11,9 +11,11 @@ import { queryKeys } from '$lib/query/query-keys'; import { untrack } from 'svelte'; import { ResourcePageLayout, type ActionButton, type StatCardConfig } from '$lib/layouts/index.js'; - import { createMutation, createQuery } from '@tanstack/svelte-query'; + import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); + const queryClient = useQueryClient(); let volumes = $state(untrack(() => data.volumes)); let requestOptions = $state(untrack(() => data.volumeRequestOptions)); @@ -31,10 +33,13 @@ const createVolumeMutation = createMutation(() => ({ mutationKey: ['volumes', 'create', envId], mutationFn: (options: VolumeCreateRequest) => volumeService.createVolume(options), - onSuccess: async (_data, options) => { + onSuccess: async (data, options) => { const name = options.name?.trim() || m.common_unknown(); - toast.success(m.common_create_success({ resource: `${m.resource_volume()} "${name}"` })); - await volumesQuery.refetch(); + toast.success( + m.common_create_success({ resource: `${m.resource_volume()} "${name}"` }), + activityToastOptions(extractActivityId(data)) + ); + await loadVolumes(); isCreateDialogOpen = false; }, onError: (_error, options) => { @@ -53,8 +58,16 @@ await createVolumeMutation.mutateAsync(options); } + async function loadVolumes(options = requestOptions) { + requestOptions = options; + volumes = await queryClient.fetchQuery({ + queryKey: queryKeys.volumes.table(envId, options), + queryFn: () => volumeService.getVolumesForEnvironment(envId, options) + }); + } + async function refresh() { - await volumesQuery.refetch(); + await loadVolumes(); } const isRefreshing = $derived(volumesQuery.isFetching && !volumesQuery.isPending); @@ -103,15 +116,7 @@ {#snippet mainContent()} - { - requestOptions = options; - await volumesQuery.refetch(); - }} - /> + {/snippet} {#snippet additionalContent()} diff --git a/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte b/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte index 97f8d824e8..fba9c0a30e 100644 --- a/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte +++ b/frontend/src/routes/(app)/volumes/[volumeName]/+page.svelte @@ -20,6 +20,7 @@ import settingsStore from '$lib/stores/config-store'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { hasPermission } from '$lib/utils/auth'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { data } = $props(); let volume = $state(untrack(() => data.volume)); @@ -60,8 +61,8 @@ result: await tryCatch(volumeService.deleteVolume(safeName)), message: m.volumes_remove_failed({ name: safeName }), setLoadingState: (value) => (isLoading.remove = value), - onSuccess: async () => { - toast.success(m.volumes_remove_success({ name: safeName })); + onSuccess: async (data) => { + toast.success(m.volumes_remove_success({ name: safeName }), activityToastOptions(extractActivityId(data))); goto('/volumes'); } }); diff --git a/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte b/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte index f43c02659b..e7c23a1370 100644 --- a/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte +++ b/frontend/src/routes/(app)/volumes/components/volume-backup-table.svelte @@ -34,6 +34,7 @@ import { environmentStore } from '$lib/stores/environment.store.svelte'; import { hasPermission } from '$lib/utils/auth'; import IfPermitted from '$lib/components/if-permitted.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { volumeName }: { volumeName: string } = $props(); @@ -85,8 +86,8 @@ async function handleCreate() { creating = true; try { - await volumeBackupService.createBackup(volumeName); - toast.success(m.common_success()); + const result = await volumeBackupService.createBackup(volumeName); + toast.success(m.common_success(), activityToastOptions(extractActivityId(result))); await loadData(requestOptions); } catch (e: any) { toast.error(e.message || m.common_failed()); @@ -104,8 +105,8 @@ destructive: true, action: async () => { try { - await volumeBackupService.deleteBackup(backup.id); - toast.success(m.common_delete_success({ resource: 'Backup' })); + const result = await volumeBackupService.deleteBackup(backup.id); + toast.success(m.common_delete_success({ resource: 'Backup' }), activityToastOptions(extractActivityId(result))); await loadData(requestOptions); } catch (e: any) { toast.error(e.message || m.common_delete_failed({ resource: 'Backup' })); @@ -173,8 +174,8 @@ destructive: !!usageWarning, action: async () => { try { - await volumeBackupService.restoreBackup(volumeName, backup.id); - toast.success(m.volumes_backup_restore_success()); + const result = await volumeBackupService.restoreBackup(volumeName, backup.id); + toast.success(m.volumes_backup_restore_success(), activityToastOptions(extractActivityId(result))); await loadData(requestOptions); } catch (e: any) { toast.error(e.message || m.common_failed()); @@ -190,8 +191,11 @@ restoringFiles = true; try { - await volumeBackupService.restoreBackupFiles(volumeName, restoreTarget.id, selectedPaths); - toast.success(m.volumes_backup_restore_files_success({ count: selectedPaths.length })); + const result = await volumeBackupService.restoreBackupFiles(volumeName, restoreTarget.id, selectedPaths); + toast.success( + m.volumes_backup_restore_files_success({ count: selectedPaths.length }), + activityToastOptions(extractActivityId(result)) + ); showRestoreFiles = false; } catch (e: any) { toast.error(e.message || m.common_failed()); @@ -378,7 +382,7 @@
No files found in this backup.
{:else}
- {#each filteredBackupFiles as filePath} + {#each filteredBackupFiles as filePath (filePath)}
(isLoading.removing = value), - onSuccess: async () => { - toast.success(m.common_remove_success({ resource: `${m.resource_volume()} "${safeName}"` })); + onSuccess: async (data) => { + toast.success( + m.common_remove_success({ resource: `${m.resource_volume()} "${safeName}"` }), + activityToastOptions(extractActivityId(data)) + ); await refreshVolumes(); } }); diff --git a/go.work.sum b/go.work.sum index 419036c7b2..44af362de0 100644 --- a/go.work.sum +++ b/go.work.sum @@ -762,6 +762,8 @@ github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeF github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y= github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -804,6 +806,7 @@ github.com/mistifyio/go-zfs/v3 v3.1.0 h1:FZaylcg0hjUp27i23VcJJQiuBeAZjrC8lPqCGM1 github.com/mistifyio/go-zfs/v3 v3.1.0/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= github.com/mistifyio/go-zfs/v4 v4.0.0 h1:sU0+5dX45tdDK5xNZ3HBi95nxUc48FS92qbIZEvpAg4= github.com/mistifyio/go-zfs/v4 v4.0.0/go.mod h1:weotFtXTHvBwhr9Mv96KYnDkTPBOHFUbm9cBmQpesL0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffe3c5c32..35354e36d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,8 @@ overrides: fast-uri: '>=3.1.2' ws: '>=8.20.1' +packageExtensionsChecksum: sha256-Yj0m2AD9TZO/cWup5bCg2l8l7VHtLu5doy9Oybhgv+w= + importers: .: @@ -5258,6 +5260,7 @@ snapshots: svelte-codemirror-editor@2.1.0(codemirror@6.0.2)(svelte@5.55.9): dependencies: + '@codemirror/search': 6.7.0 codemirror: 6.0.2 svelte: 5.55.9 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba15f54971..9507684427 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,12 @@ packages: - frontend - tests - email-templates + +packageExtensions: + svelte-codemirror-editor@2.1.0: + dependencies: + "@codemirror/search": ^6.7.0 + overrides: devalue: ">=5.8.1" valibot: ^1.2.0 diff --git a/tests/setup/gitops.setup.ts b/tests/setup/gitops.setup.ts index 575489199f..ad881eb637 100644 --- a/tests/setup/gitops.setup.ts +++ b/tests/setup/gitops.setup.ts @@ -18,7 +18,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Step 1: Create a Git Repository in Arcane pointing to GitHub await page.goto('/customize/git-repositories'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Check if test repo already exists const existingRepo = page.getByRole('cell', { name: GITOPS_REPO_NAME }); @@ -68,7 +68,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Step 2: Create GitOps Sync await page.goto('/environments/0/gitops'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Check if sync already exists const existingSync = page.getByRole('cell', { name: GITOPS_SYNC_NAME }); @@ -151,7 +151,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Step 3: Trigger initial sync to create the managed project console.log('Triggering initial sync...'); await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Find the sync row and trigger sync const syncRow = page.locator('tr').filter({ hasText: GITOPS_SYNC_NAME }); @@ -178,7 +178,7 @@ setup('create gitops sync in arcane', async ({ page }) => { // Verify project was created await page.goto('/projects'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.waitForTimeout(2000); console.log('GitOps test setup complete!'); diff --git a/tests/spec/activity-center.spec.ts b/tests/spec/activity-center.spec.ts new file mode 100644 index 0000000000..ccf1d7a38c --- /dev/null +++ b/tests/spec/activity-center.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, type Page } from '@playwright/test'; + +function extractActivityId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + + const activityId = (value as { activityId?: unknown }).activityId; + if (typeof activityId === 'string' && activityId.trim()) return activityId; + + if (Array.isArray(value)) { + for (const item of value) { + const nested = extractActivityId(item); + if (nested) return nested; + } + return undefined; + } + + for (const item of Object.values(value)) { + const nested = extractActivityId(item); + if (nested) return nested; + } + + return undefined; +} + +function extractCreatedNetworkId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const data = (value as { data?: { id?: unknown } }).data; + return typeof data?.id === 'string' ? data.id : undefined; +} + +async function createNetworkViaUI(page: Page, networkName: string) { + await page.goto('/networks'); + await page.waitForLoadState('load'); + await expect(page.getByRole('heading', { level: 1, name: 'Networks' })).toBeVisible(); + + await page.getByRole('button', { name: 'Create Network' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.locator('#network-name').fill(networkName); + + const createRequest = page.waitForResponse( + (response) => { + const request = response.request(); + return ( + request.method() === 'POST' && + /\/api\/environments\/[^/]+\/networks$/.test(new URL(response.url()).pathname) + ); + }, + { timeout: 15000 } + ); + + await dialog.getByRole('button', { name: 'Create Network' }).click(); + const createResponse = await createRequest; + const body = await createResponse.json(); + if (!createResponse.ok()) { + throw new Error(`Failed to create network ${networkName}: ${createResponse.status()}`); + } + + return { + activityId: extractActivityId(body), + networkId: extractCreatedNetworkId(body) + }; +} + +async function removeNetworkViaApi(page: Page, networkId: string | undefined) { + if (!networkId) return; + await page.request + .delete(`/api/environments/0/networks/${encodeURIComponent(networkId)}`) + .catch(() => undefined); +} + +async function openActivityCenter(page: Page) { + await page.getByRole('button', { name: 'Open activity center' }).first().click(); + const activityCenter = page.getByRole('dialog', { name: 'Activity Center' }); + await expect(activityCenter).toBeVisible(); + return activityCenter; +} + +test.describe('Activity Center', () => { + test('shows completed activity details for UI-triggered work', async ({ page }) => { + const networkName = `e2e-activity-network-${Date.now()}`; + let networkId: string | undefined; + + try { + const created = await createNetworkViaUI(page, networkName); + networkId = created.networkId; + expect(created.activityId).toBeTruthy(); + + const activityCenter = await openActivityCenter(page); + await expect(activityCenter.getByRole('button', { name: 'Running' })).toBeVisible(); + await expect(activityCenter.getByRole('button', { name: 'Failed' })).toBeVisible(); + await activityCenter.getByRole('button', { name: 'Completed' }).click(); + + const activityItem = activityCenter + .locator('button[aria-label="Activity Center"]') + .filter({ hasText: networkName }) + .first(); + await expect(activityItem).toBeVisible(); + await expect(activityItem).toContainText('Resource Action'); + await expect(activityItem).toContainText('Success'); + await expect(activityItem).toContainText('Local'); + await expect(activityItem).toContainText(/Started by/i); + + await activityItem.click(); + await expect(activityCenter.getByText('Output', { exact: true })).toBeVisible(); + await expect(activityCenter.getByText('Creating network').first()).toBeVisible(); + await expect(activityCenter.getByText('Network created successfully').first()).toBeVisible(); + await expect(activityCenter.getByText('Source environment')).toBeVisible(); + await expect(activityCenter.getByText('Started by', { exact: true })).toBeVisible(); + } finally { + await removeNetworkViaApi(page, networkId); + } + }); +}); diff --git a/tests/spec/api-keys.spec.ts b/tests/spec/api-keys.spec.ts index 531eec2640..b11a42b493 100644 --- a/tests/spec/api-keys.spec.ts +++ b/tests/spec/api-keys.spec.ts @@ -5,7 +5,7 @@ const API_KEYS_ROUTE = '/settings/api-keys'; async function navigateToApiKeys(page: Page) { await page.goto(API_KEYS_ROUTE); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } test.describe('API Keys Page', () => { diff --git a/tests/spec/builds.spec.ts b/tests/spec/builds.spec.ts index ade00d7411..5a3c6290ae 100644 --- a/tests/spec/builds.spec.ts +++ b/tests/spec/builds.spec.ts @@ -20,7 +20,7 @@ const FIELD_LABELS = { async function navigateToBuildWorkspace(page: Page) { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { level: 1, name: /Build Workspace/i })).toBeVisible(); } diff --git a/tests/spec/container-grouped-pagination.spec.ts b/tests/spec/container-grouped-pagination.spec.ts index 429b4b110a..f7763b6011 100644 --- a/tests/spec/container-grouped-pagination.spec.ts +++ b/tests/spec/container-grouped-pagination.spec.ts @@ -153,7 +153,7 @@ test('grouped containers do not split the same project across pages', async ({ p }); await page.goto('/containers'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.setViewportSize({ width: 1440, height: 900 }); diff --git a/tests/spec/containers.spec.ts b/tests/spec/containers.spec.ts index 0571b3e3c9..1d30cb403a 100644 --- a/tests/spec/containers.spec.ts +++ b/tests/spec/containers.spec.ts @@ -6,7 +6,7 @@ const CONTAINERS_ROUTE = '/containers'; async function navigateToContainers(page: Page) { await page.goto(CONTAINERS_ROUTE); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } let containersData: Paginated = { data: [], pagination: { totalItems: 0 } }; @@ -66,7 +66,7 @@ test.describe('Containers Page', () => { test.skip(!running, 'No running container available'); await page.goto(`/containers/${running!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: 'Logs' }).click(); @@ -87,7 +87,7 @@ test.describe('Containers Page', () => { test.skip(!stopped, 'No stopped container available'); await page.goto(`/containers/${stopped!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: 'Logs' }).click(); diff --git a/tests/spec/converter.spec.ts b/tests/spec/converter.spec.ts index 0fd52b49d3..60641cbea1 100644 --- a/tests/spec/converter.spec.ts +++ b/tests/spec/converter.spec.ts @@ -23,33 +23,20 @@ const SELECTORS = { }; async function openConvertFromDockerRun(page: Page) { - // Wait for the page to be fully loaded - await page.waitForLoadState('networkidle'); - - // Split-button chevron trigger on /projects/new. (Trigger is rendered via child snippet, - // so data-slot="dropdown-menu-trigger" may not be present on the final button element.) - let dropdownTrigger = page.locator('button.rounded-l-none.px-2').first(); - if ((await dropdownTrigger.count()) === 0) { - // Fallback for markup changes: use the old icon-only heuristic - dropdownTrigger = page.locator('button:has(svg)').filter({ hasText: '' }).last(); - } + await page.waitForLoadState('load'); + const createProjectGroup = page + .getByRole('group') + .filter({ has: page.getByRole('button', { name: 'Create Project' }) }) + .first(); + const dropdownTrigger = createProjectGroup.locator('button').last(); await expect(dropdownTrigger).toBeVisible(); await dropdownTrigger.click(); - // Prefer text match, but keep positional fallback for non-English locales. - const menuItems = page.locator('[data-slot="dropdown-menu-item"], [role="menuitem"]'); - await expect(menuItems.first()).toBeVisible(); - - const convertItemByText = menuItems.filter({ hasText: /Convert from Docker Run/i }).first(); - if (await convertItemByText.count()) { - await convertItemByText.click(); - } else { - // Item order in this menu is: template, converter, git repo, separator, ... - await menuItems.nth(1).click(); - } + const menu = page.locator('[data-slot="dropdown-menu-content"]:visible').last(); + await expect(menu).toBeVisible(); + await menu.getByRole('menuitem', { name: /Convert from Docker Run/i }).click(); - // Ensure converter dialog is open before interacting with inputs await expect(page.getByRole('button', SELECTORS.convertButton())).toBeVisible(); } @@ -66,7 +53,7 @@ async function setupMockConvert(page: Page, payload: ConvertResponse) { test.describe('Docker Run to Compose Converter', () => { test.beforeEach(async ({ page }) => { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('should convert simple docker run command', async ({ page }) => { diff --git a/tests/spec/dashboard-system-stats.spec.ts b/tests/spec/dashboard-system-stats.spec.ts index 2e8c93262d..883921b087 100644 --- a/tests/spec/dashboard-system-stats.spec.ts +++ b/tests/spec/dashboard-system-stats.spec.ts @@ -124,7 +124,7 @@ test.describe('Dashboard system stats websocket', () => { await mockDashboardStatsWebSocket(page); await page.goto(defaultDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('tab', { name: 'All' })).toHaveAttribute('data-state', 'active'); await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible(); @@ -146,9 +146,10 @@ test.describe('Dashboard system stats websocket', () => { const requestPaths = collectDashboardRequestPaths(page); await page.goto(defaultDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); + await expect(page.getByRole('heading', { name: 'Environment Board' })).toBeVisible(); - expect(requestPaths).toContain('/api/dashboard/environments'); + await expect.poll(() => requestPaths).toContain('/api/dashboard/environments'); for (const blockedPattern of [ /\/api\/environments\/[^/]+\/dashboard$/, @@ -173,10 +174,10 @@ test.describe('Dashboard system stats websocket', () => { const requestPaths = collectDashboardRequestPaths(page); await page.goto(defaultDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: 'Current' }).click(); await expect(page).toHaveURL(currentDashboardPath); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); expect( countMatchingRequests(requestPaths, /\/api\/environments\/[^/]+\/system\/docker\/info$/) diff --git a/tests/spec/edge-agent.spec.ts b/tests/spec/edge-agent.spec.ts index 06f2974f98..a3de3f1e39 100644 --- a/tests/spec/edge-agent.spec.ts +++ b/tests/spec/edge-agent.spec.ts @@ -6,7 +6,7 @@ const ROUTES = { async function openNewEnvironmentSheet(page: Page) { await page.goto(ROUTES.environments); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const addButton = page.getByRole('button', { name: 'Add Environment', exact: true }); await expect(addButton).toBeVisible(); diff --git a/tests/spec/environment-settings.spec.ts b/tests/spec/environment-settings.spec.ts index 94deaafe1c..19e2708c2a 100644 --- a/tests/spec/environment-settings.spec.ts +++ b/tests/spec/environment-settings.spec.ts @@ -4,14 +4,14 @@ const LOCAL_ENV_ID = '0'; async function openEnvironment(page: Page, environmentId: string) { await page.goto(`/environments/${environmentId}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('#env-name')).toBeVisible(); await expect(page.getByRole('button', { name: 'Save', exact: true }).first()).toBeVisible(); } async function createDirectEnvironmentViaUI(page: Page, environmentName: string) { await page.goto('/environments'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('button', { name: 'Add Environment', exact: true }).click(); await expect(page.getByText('Create New Agent Environment')).toBeVisible(); @@ -27,7 +27,7 @@ async function createDirectEnvironmentViaUI(page: Page, environmentName: string) async function deleteEnvironmentViaUI(page: Page, environmentName: string) { await page.goto('/environments'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const envRow = page.locator('tr').filter({ has: page.getByRole('button', { name: environmentName, exact: true }) diff --git a/tests/spec/image-updates.spec.ts b/tests/spec/image-updates.spec.ts index 16d486311c..c5ec98cda3 100644 --- a/tests/spec/image-updates.spec.ts +++ b/tests/spec/image-updates.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Locator, type Page } from '@playwright/test'; import { fetchImagesWithRetry } from '../utils/fetch.util'; const ROUTES = { @@ -42,7 +42,7 @@ interface UpdateSummary { async function navigateToImages(page: Page) { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } async function fetchImagesTotal(page: Page, updatesFilter?: string): Promise { @@ -59,6 +59,43 @@ async function fetchImagesTotal(page: Page, updatesFilter?: string): Promise { + const pageHeader = page + .locator('main header') + .filter({ has: page.getByRole('heading', { name: 'Images', exact: true }) }) + .first(); + await expect(pageHeader).toBeVisible(); + + const directButton = pageHeader + .getByRole('button', { name: 'Check Updates', exact: true }) + .filter({ visible: true }) + .first(); + + if ( + await expect(directButton) + .toBeVisible({ timeout: 5000 }) + .then( + () => true, + () => false + ) + ) { + return directButton; + } + + const menuTrigger = pageHeader + .getByRole('button', { name: 'More actions' }) + .filter({ visible: true }) + .first(); + await expect(menuTrigger).toBeVisible(); + await menuTrigger.click(); + + const menu = page.locator('[data-slot="dropdown-menu-content"]:visible').last(); + await expect(menu).toBeVisible(); + const menuItem = menu.getByRole('menuitem', { name: 'Check Updates', exact: true }).first(); + await expect(menuItem).toBeVisible(); + return menuItem; +} + let realImages: any[] = []; test.beforeEach(async ({ page }) => { @@ -76,42 +113,26 @@ test.describe('Image Update UI - Check All Updates Button', () => { test('should display the Check Updates button on images page', async ({ page }) => { await navigateToImages(page); - // Find the Check Updates button - it might be directly visible or in a menu - let checkUpdatesButton = page.getByRole('button', { name: 'Check Updates' }); - const isDirectlyVisible = await checkUpdatesButton.isVisible().catch(() => false); - - if (!isDirectlyVisible) { - // Try to find and click the overflow menu trigger - const menuTrigger = page.getByRole('button', { name: 'More actions' }); - await menuTrigger.click(); - checkUpdatesButton = page.getByRole('menuitem', { name: 'Check Updates' }); - } - + const checkUpdatesButton = await getCheckUpdatesAction(page); await expect(checkUpdatesButton).toBeVisible(); }); test('should trigger bulk update check when clicking Check Updates button', async ({ page }) => { await navigateToImages(page); - // Find the Check Updates button - it might be directly visible or in a menu - let checkUpdatesButton = page.getByRole('button', { name: 'Check Updates' }); - const isDirectlyVisible = await checkUpdatesButton.isVisible().catch(() => false); - - if (!isDirectlyVisible) { - // Try to find and click the overflow menu trigger - const menuTrigger = page.getByRole('button', { name: 'More actions' }); - await menuTrigger.click(); - checkUpdatesButton = page.getByRole('menuitem', { name: 'Check Updates' }); - } - + const checkUpdatesButton = await getCheckUpdatesAction(page); await expect(checkUpdatesButton).toBeVisible(); + const checkAllResponsePromise = page.waitForResponse((response) => { + const request = response.request(); + if (request.method() !== 'POST') return false; + return new URL(response.url()).pathname === ROUTES.apiImageUpdatesCheckAll; + }); + await checkUpdatesButton.click(); - if (isDirectlyVisible) { - // If it's a direct button, it should show a loading state - await expect(checkUpdatesButton).toContainText(/checking/i, { timeout: 10000 }); - } + const checkAllResponse = await checkAllResponsePromise; + expect(checkAllResponse.ok()).toBeTruthy(); // Eventually a success or completion toast should appear await expect(page.locator('li[data-sonner-toast]')).toBeVisible({ timeout: 60000 }); @@ -306,7 +327,7 @@ test.describe('Image Update UI Integration', () => { // Navigate to image detail await page.goto(`/images/${encodeURIComponent(testImage.id)}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The detail page should load await expect(page.locator('h1, h2, [data-testid="image-detail"]').first()).toBeVisible({ diff --git a/tests/spec/images.spec.ts b/tests/spec/images.spec.ts index 79494648cd..96425690fe 100644 --- a/tests/spec/images.spec.ts +++ b/tests/spec/images.spec.ts @@ -9,7 +9,7 @@ const ROUTES = { async function navigateToImages(page: Page) { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } async function fetchAllImagesForUsage(page: Page): Promise { @@ -135,7 +135,7 @@ test.describe('Images Page', () => { await firstRow.getByRole('button', { name: 'Open menu' }).click(); await page.getByRole('menuitem', { name: 'Pull' }).click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect( page.locator(`li[data-sonner-toast][data-type="success"] div[data-title]`) diff --git a/tests/spec/network.spec.ts b/tests/spec/network.spec.ts index 43610f0cad..5ad0c0cea0 100644 --- a/tests/spec/network.spec.ts +++ b/tests/spec/network.spec.ts @@ -3,7 +3,7 @@ import { fetchNetworksCountsWithRetry } from '../utils/fetch.util'; async function navigateToNetworks(page: Page) { await page.goto('/networks'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } test.beforeEach(async ({ page }) => { @@ -28,6 +28,7 @@ async function createNetworkViaUI(page: Page, networkName: string) { await page.getByRole('dialog').getByRole('button', { name: 'Create Network' }).click(); const createResponse = await createRequest; + const responseBody = await createResponse.json().catch(() => undefined); if (!createResponse.ok()) { const responseText = await createResponse.text().catch(() => ''); throw new Error( @@ -35,8 +36,23 @@ async function createNetworkViaUI(page: Page, networkName: string) { ); } - await navigateToNetworks(page); - await expect(await findNetworkRow(page, networkName, 15)).toBeVisible(); + return responseBody?.data?.id ?? networkName; +} + +async function createNetworkViaApi(page: Page, networkName: string) { + const response = await page.request.post('/api/environments/0/networks', { + data: { + name: networkName, + options: { + driver: 'bridge' + } + } + }); + if (!response.ok()) { + throw new Error( + `Failed to create network ${networkName}: ${response.status()} ${await response.text()}` + ); + } } async function findNetworkRow(page: Page, networkName: string, maxRetries = 10) { @@ -54,19 +70,10 @@ async function findNetworkRow(page: Page, networkName: string, maxRetries = 10) return page.locator('tbody tr', { has: page.getByText(networkName) }).first(); } -async function removeNetworkViaUI(page: Page, networkName: string) { - await navigateToNetworks(page); - - const row = await findNetworkRow(page, networkName, 4); - if ((await row.count()) === 0) return; - - await row.locator('a[href*="/networks/"]').first().click(); - await expect(page).toHaveURL(/\/networks\/.+/); - await page.getByRole('button', { name: 'Remove', exact: true }).click(); - await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); - await expect( - page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') - ).toBeVisible(); +async function removeNetworkViaApi(page: Page, networkName: string) { + await page.request + .delete(`/api/environments/0/networks/${encodeURIComponent(networkName)}`) + .catch(() => undefined); } test.describe('Networks Page', () => { @@ -92,51 +99,45 @@ test.describe('Networks Page', () => { const networkName = `e2e-table-network-${Date.now()}`; await navigateToNetworks(page); try { - await createNetworkViaUI(page, networkName); + await createNetworkViaApi(page, networkName); await navigateToNetworks(page); await expect(page.locator('table')).toBeVisible(); await expect(page.getByRole('button', { name: 'Name' })).toBeVisible(); await expect(await findNetworkRow(page, networkName)).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); test('Open Create Network sheet', async ({ page }) => { const networkName = `test-network-${Date.now()}`; try { - await createNetworkViaUI(page, networkName); - await navigateToNetworks(page); - await expect(await findNetworkRow(page, networkName)).toBeVisible(); + const networkId = await createNetworkViaUI(page, networkName); + const response = await page.request.get( + `/api/environments/0/networks/${encodeURIComponent(networkId)}` + ); + expect(response.ok()).toBe(true); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); test('Inspect Network from row actions', async ({ page }) => { const networkName = `e2e-inspect-network-${Date.now()}`; try { - await createNetworkViaUI(page, networkName); - await navigateToNetworks(page); - - const row = await findNetworkRow(page, networkName); - await expect(row).toBeVisible(); - await row.locator('a[href*="/networks/"]').first().click(); + await createNetworkViaApi(page, networkName); + await page.goto(`/networks/${encodeURIComponent(networkName)}`); await expect(page).toHaveURL(/\/networks\/.+/); await expect(page.getByRole('heading', { level: 1, name: networkName })).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); - test('Remove Network from table', async ({ page }) => { + test('Remove Network from details page', async ({ page }) => { const networkName = `test-remove-network-${Date.now()}`; - await createNetworkViaUI(page, networkName); - await navigateToNetworks(page); - const row = await findNetworkRow(page, networkName); - await expect(row).toBeVisible(); - - await row.locator('a[href*="/networks/"]').first().click(); + await createNetworkViaApi(page, networkName); + await page.goto(`/networks/${encodeURIComponent(networkName)}`); await expect(page).toHaveURL(/\/networks\/.+/); await page.getByRole('button', { name: 'Remove', exact: true }).click(); await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); @@ -144,9 +145,14 @@ test.describe('Networks Page', () => { page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') ).toBeVisible(); - await navigateToNetworks(page); - const removedRow = await findNetworkRow(page, networkName, 2); - await expect(removedRow).not.toBeVisible(); + await expect + .poll(async () => { + const response = await page.request.get( + `/api/environments/0/networks/${encodeURIComponent(networkName)}` + ); + return response.status(); + }) + .toBe(404); }); test('Default networks cannot be removed on details page', async ({ page }) => { @@ -156,7 +162,7 @@ test.describe('Networks Page', () => { .first(); await expect(bridgeRow).toBeVisible(); await bridgeRow.locator('a[href*="/networks/"]').first().click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const removeBtn = page.getByRole('button', { name: 'Remove' }); await expect(removeBtn).toBeDisabled(); @@ -165,16 +171,13 @@ test.describe('Networks Page', () => { test('Details page shows usage badge', async ({ page }) => { const networkName = `e2e-badge-network-${Date.now()}`; try { - await createNetworkViaUI(page, networkName); - await navigateToNetworks(page); - const row = await findNetworkRow(page, networkName); - await expect(row).toBeVisible(); - await row.locator('a[href*="/networks/"]').first().click(); - await page.waitForLoadState('networkidle'); + await createNetworkViaApi(page, networkName); + await page.goto(`/networks/${encodeURIComponent(networkName)}`); + await page.waitForLoadState('load'); await expect(page.getByText('Unused').first()).toBeVisible(); } finally { - await removeNetworkViaUI(page, networkName); + await removeNetworkViaApi(page, networkName); } }); }); diff --git a/tests/spec/project.spec.ts b/tests/spec/project.spec.ts index c206664e7d..ca60ffa011 100644 --- a/tests/spec/project.spec.ts +++ b/tests/spec/project.spec.ts @@ -17,7 +17,7 @@ const DEPLOY_STREAM_SUCCESS = async function navigateToProjects(page: Page) { await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } async function setCodeMirrorValue(page: Page, editor: Locator, text: string) { @@ -52,16 +52,12 @@ async function openDropdownMenu(page: Page, trigger: Locator) { } async function clickProjectsPageUpdateAction(page: Page) { - const updateProjectsButton = page.getByRole('button', { name: 'Update Projects', exact: true }); - if (await updateProjectsButton.isVisible().catch(() => false)) { - await updateProjectsButton.click(); - return; - } - - const moreActionsButton = page.getByRole('button', { name: 'More actions', exact: true }); - await expect(moreActionsButton).toBeVisible(); - await moreActionsButton.click(); - await page.getByRole('menuitem', { name: 'Update Projects', exact: true }).click(); + const updateProjectsButton = page + .locator('main button:visible') + .filter({ hasText: 'Update Projects' }) + .first(); + await expect(updateProjectsButton).toBeVisible(); + await updateProjectsButton.click(); } async function clickProjectDetailUpdateAction(page: Page) { @@ -139,7 +135,7 @@ async function createProjectViaUI(page: Page, projectName: string) { const envFile = TEST_ENV_FILE.replace(/CONTAINER_NAME=.*/m, `CONTAINER_NAME=${containerName}`); await page.goto(ROUTES.newProject); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('button', { name: 'My New Project' }).click(); await page.getByRole('textbox', { name: 'My New Project' }).fill(projectName); @@ -203,7 +199,7 @@ async function destroyProjectByNameViaUI(page: Page, projectName: string) { } await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const searchInput = page.getByPlaceholder('Search…'); if (await searchInput.isVisible().catch(() => false)) { @@ -302,7 +298,7 @@ test.describe('Projects Page', () => { test('should show project actions menu', async ({ page }) => { test.skip(!realProjects.length, 'No projects available for actions menu test'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const firstRow = page.locator('tbody tr').first(); const menu = await openDropdownMenu(page, firstRow.getByRole('button', { name: 'Open menu' })); @@ -337,7 +333,7 @@ test.describe('Projects Page', () => { }); await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.locator('tbody tr').filter({ hasText: projectName })).toHaveCount(0); await page.getByLabel('Show archived').check(); @@ -367,7 +363,7 @@ test.describe('Projects Page', () => { test('should navigate to project details when project name is clicked', async ({ page }) => { test.skip(!realProjects.length, 'No projects available for navigation test'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Get the first project link that points to /projects/ (not the "Git" indicator link) const firstProjectLink = page .locator('tbody tr') @@ -427,7 +423,7 @@ test.describe('Projects Page', () => { test('should display project status badges', async ({ page }) => { test.skip(!realProjects.length, 'No projects available for status badge test'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const runningProjects = realProjects.filter((p) => p.status === 'running'); const stoppedProjects = realProjects.filter((p) => p.status === 'stopped'); @@ -445,7 +441,7 @@ test.describe('Projects Page', () => { test.describe('New Compose Project Page', () => { test.beforeEach(async ({ page }) => { await page.goto(ROUTES.newProject); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('should display the create project form', async ({ page }) => { @@ -635,7 +631,7 @@ test.describe('New Compose Project Page', () => { await expect(page.getByRole('button', { name: projectName })).toBeVisible(); await page.getByRole('tab', { name: 'Services' }).click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const serviceTable = page.getByRole('table'); const serviceNameWhenStopped = serviceTable.getByText('nginx', { @@ -661,7 +657,7 @@ test.describe('New Compose Project Page', () => { await deployButton.click(); await page.waitForTimeout(5000); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); expect(projectPullRequestCount).toBe(0); await expect(page.getByText('Running', { exact: true }).first()).toBeVisible({ @@ -760,7 +756,7 @@ test.describe('New Compose Project Page', () => { try { await createProjectViaUI(page, projectName); await page.goto(ROUTES.page); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.route('**/api/environments/*/projects/*/redeploy', async (route) => { if (route.request().method() !== 'POST') { @@ -837,7 +833,7 @@ test.describe('New Compose Project Page', () => { test.describe('GitOps Managed Project', () => { test('should navigate back to gitops when opened from the git syncs page', async ({ page }) => { await page.goto('/environments/0/gitops'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const projectLink = page.locator('tbody tr').locator('a[href^="/projects/"]').first(); test.skip((await projectLink.count()) === 0, 'No GitOps project links found'); @@ -858,12 +854,12 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Navigate to Configuration tab const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Verify the GitOps read-only alert is visible (title contains "Git" and "Read-only") await expect(page.getByText('Git Read-only')).toBeVisible(); @@ -875,11 +871,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Verify the Sync from Git button is present await expect(page.getByRole('button', { name: 'Sync from Git' })).toBeVisible(); @@ -890,7 +886,7 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects with sync commit found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The commit hash should be visible somewhere on the page const commitHash = gitOpsProject!.lastSyncCommit!.substring(0, 7); @@ -902,7 +898,7 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The name button should be disabled for GitOps-managed projects const nameButton = page.getByRole('button', { name: gitOpsProject!.name }); @@ -914,11 +910,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.waitForTimeout(800); const composeContent = page.locator('.cm-editor:visible').first().locator('.cm-content'); @@ -932,11 +928,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!gitOpsProject, 'No GitOps-managed projects found'); await page.goto(`/projects/${gitOpsProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.waitForTimeout(800); const envEditor = page.locator('.cm-editor:visible').nth(1); @@ -955,7 +951,7 @@ test.describe('GitOps Managed Project', () => { }); if (await layoutSwitch.count()) { await layoutSwitch.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const envFileButton = page.getByRole('button', { name: '.env' }).first(); await expect(envFileButton).toBeVisible(); @@ -973,7 +969,7 @@ test.describe('GitOps Managed Project', () => { test.skip(!regularProject, 'No regular (non-GitOps) stopped projects found'); await page.goto(`/projects/${regularProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // The name button should be enabled for regular projects that are stopped const nameButton = page.getByRole('button', { name: regularProject!.name }); @@ -982,7 +978,7 @@ test.describe('GitOps Managed Project', () => { // Navigate to Configuration tab const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // GitOps alert should NOT be visible await expect(page.getByText('Git Read-only')).not.toBeVisible(); @@ -998,11 +994,11 @@ test.describe('GitOps Managed Project', () => { test.skip(!regularProject, 'No regular (non-GitOps) projects found'); await page.goto(`/projects/${regularProject!.id}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); // Verify no GitOps-related UI elements await expect(page.getByText(/managed by Git\./i)).not.toBeVisible(); @@ -1016,7 +1012,7 @@ test.describe('Project Detail Page', () => { const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const backLink = page.getByRole('link', { name: /^Back$/i }).first(); await expect(backLink).toBeVisible(); @@ -1031,7 +1027,7 @@ test.describe('Project Detail Page', () => { const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('button', { name: firstProject.name, exact: false })).toBeVisible(); @@ -1044,7 +1040,7 @@ test.describe('Project Detail Page', () => { test.skip(!realProjects.length, 'No projects available for navigation test'); const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('tab', { name: /Services/i })).toBeVisible(); await expect(page.getByRole('tab', { name: /Configuration|Config/i })).toBeVisible(); @@ -1086,7 +1082,7 @@ test.describe('Project Detail Page', () => { }); await page.goto(`/projects/${projectWithServices!.id || projectWithServices!.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const clicked = await clickProjectDetailUpdateAction(page); test.skip(!clicked, 'Current project detail view has no clickable update action'); @@ -1101,7 +1097,7 @@ test.describe('Project Detail Page', () => { const projectWithServices = realProjects.find((p) => (p.serviceCount ?? 0) > 0) ?? realProjects[0]!; await page.goto(`/projects/${projectWithServices.id || projectWithServices.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await page.getByRole('tab', { name: /Services/i }).click(); @@ -1125,7 +1121,7 @@ test.describe('Project Detail Page', () => { const firstProject = realProjects[0]; await page.goto(`/projects/${firstProject.id || firstProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); @@ -1189,11 +1185,11 @@ test.describe('Project Detail Page', () => { test.skip(!regularProject, 'No regular (non-GitOps) projects found'); await page.goto(`/projects/${regularProject!.id || regularProject!.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); let layoutSwitch = page.getByRole('switch', { name: /Classic|Tree View/i @@ -1213,12 +1209,12 @@ test.describe('Project Detail Page', () => { } await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const restoredConfigTab = page.getByRole('tab', { name: /Configuration|Config/i }); if ((await restoredConfigTab.getAttribute('aria-selected')) !== 'true') { await restoredConfigTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } await expect(projectFilesHeading).toBeVisible(); @@ -1255,7 +1251,7 @@ test.describe('Project Detail Page', () => { try { const configTab = page.getByRole('tab', { name: /Configuration|Config/i }); await configTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); let composeEditor = page.locator('.cm-editor:visible').first(); await expect(composeEditor).toBeVisible(); @@ -1296,12 +1292,12 @@ test.describe('Project Detail Page', () => { } await page.reload(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const restoredConfigTab = page.getByRole('tab', { name: /Configuration|Config/i }); if ((await restoredConfigTab.getAttribute('aria-selected')) !== 'true') { await restoredConfigTab.click(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); } composeEditor = page.locator('.cm-editor:visible').first(); @@ -1324,7 +1320,7 @@ test.describe('Project Detail Page', () => { const targetProject = runningProject!; await page.goto(`/projects/${targetProject.id || targetProject.name}`); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const logsTab = page.getByRole('tab', { name: /Logs/i }); await expect(logsTab).toBeEnabled(); diff --git a/tests/spec/registries.spec.ts b/tests/spec/registries.spec.ts index 57c0efa94b..5346ccd5b1 100644 --- a/tests/spec/registries.spec.ts +++ b/tests/spec/registries.spec.ts @@ -78,7 +78,7 @@ async function mockRegistryPullUsage( test.describe('Container Registries', () => { test.beforeEach(async ({ page }) => { await page.goto(route); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); }); test('should display title and subtitle, and refresh', async ({ page }) => { @@ -118,7 +118,7 @@ test.describe('Container Registries', () => { await mockRegistryList(page); await mockRegistryPullUsage(page, { remaining: 76, limit: 100, observedPulls: 4 }); await page.goto(route); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const table = page.getByRole('table'); await expect(table.getByText('Pull Usage')).toBeVisible(); @@ -131,7 +131,7 @@ test.describe('Container Registries', () => { await mockRegistryList(page); await mockRegistryPullUsage(page, { observedPulls: 4 }); await page.goto(route); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const table = page.getByRole('table'); await expect(table.getByText('Pull Usage')).toBeVisible(); diff --git a/tests/spec/settings-notifications.spec.ts b/tests/spec/settings-notifications.spec.ts index e9810bf10d..322c69b379 100644 --- a/tests/spec/settings-notifications.spec.ts +++ b/tests/spec/settings-notifications.spec.ts @@ -76,7 +76,7 @@ test.describe('Notification settings', () => { }); await page.goto('/settings/notifications'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible(); return { diff --git a/tests/spec/swarm-ui.spec.ts b/tests/spec/swarm-ui.spec.ts index 94fbe058d3..53f5d82fd0 100644 --- a/tests/spec/swarm-ui.spec.ts +++ b/tests/spec/swarm-ui.spec.ts @@ -31,7 +31,7 @@ test.describe('Swarm UI', () => { page }) => { await page.goto('/swarm/cluster'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Cluster', level: 1 })).toBeVisible(); @@ -57,7 +57,7 @@ test.describe('Swarm UI', () => { test('configs page renders name/data fields and empty state', async ({ page }) => { await mockConfigs(page); await page.goto('/swarm/configs'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Configs', level: 1 })).toBeVisible(); await expect( @@ -72,7 +72,7 @@ test.describe('Swarm UI', () => { test('secrets page renders name/data fields and empty state', async ({ page }) => { await mockSecrets(page); await page.goto('/swarm/secrets'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Secrets', level: 1 })).toBeVisible(); await expect( diff --git a/tests/spec/token-refresh.spec.ts b/tests/spec/token-refresh.spec.ts index 18ab2b9075..a056837276 100644 --- a/tests/spec/token-refresh.spec.ts +++ b/tests/spec/token-refresh.spec.ts @@ -85,12 +85,12 @@ test.describe('Token refresh behaviour', () => { }) => { await registerTokenSeeding(page); const wasRefreshCalled = await mockRefreshSuccess(page); - await injectVersionMismatch401Once(page, /\/api\/auth\/me$/); + await injectVersionMismatch401Once(page, /\/api\/auth\/me(?:\?.*)?$/); await page.goto('/dashboard'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); - expect(wasRefreshCalled()).toBe(true); + await expect.poll(() => wasRefreshCalled()).toBe(true); await expect(page).toHaveURL('/dashboard'); await expect(page.getByRole('button', { name: 'Sign in to Arcane' })).not.toBeVisible(); }); @@ -100,12 +100,12 @@ test.describe('Token refresh behaviour', () => { }) => { await registerTokenSeeding(page); const wasRefreshCalled = await mockRefreshSuccess(page); - await injectVersionMismatch401Once(page, /\/api\/environments\/0\/containers/); + await injectVersionMismatch401Once(page, /\/api\/environments\/[^/]+\/containers/); await page.goto('/containers'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); - expect(wasRefreshCalled()).toBe(true); + await expect.poll(() => wasRefreshCalled()).toBe(true); await expect(page).toHaveURL('/containers'); await expect(page.getByRole('heading', { name: 'Containers', level: 1 })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign in to Arcane' })).not.toBeVisible(); @@ -140,7 +140,7 @@ test.describe('Token refresh behaviour', () => { await page.context().clearCookies(); await page.goto('/dashboard'); await page.waitForURL(/\/login/, { timeout: 10_000 }); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page).toHaveURL(/\/login/); await expect( page.getByRole('button', { name: 'Sign in to Arcane', exact: true }) diff --git a/tests/spec/volumes.spec.ts b/tests/spec/volumes.spec.ts index 2ff8ce39f5..9d066e4bc8 100644 --- a/tests/spec/volumes.spec.ts +++ b/tests/spec/volumes.spec.ts @@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => { async function openCreateVolumeSheet(page: Page) { await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByRole('heading', { name: 'Volumes', level: 1 })).toBeVisible(); const createButton = page.getByRole('button', { name: 'Create Volume' }).first(); @@ -30,46 +30,46 @@ async function openCreateVolumeSheet(page: Page) { async function createVolumeViaUI(page: Page, volumeName: string) { await openCreateVolumeSheet(page); await page.getByRole('dialog').locator('input[type="text"]').first().fill(volumeName); + const createRequest = page.waitForResponse( + (response) => { + const request = response.request(); + return ( + request.method() === 'POST' && + /\/api\/environments\/[^/]+\/volumes$/.test(new URL(response.url()).pathname) + ); + }, + { timeout: 15000 } + ); await page.getByRole('dialog').getByRole('button', { name: 'Create Volume' }).click(); + const createResponse = await createRequest; + if (!createResponse.ok()) { + throw new Error( + `Failed to create volume ${volumeName}: ${createResponse.status()} ${await createResponse.text()}` + ); + } await expect( page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') ).toBeVisible(); } -async function findVolumeRow(page: Page, volumeName: string, maxRetries = 10) { - for (let i = 0; i < maxRetries; i++) { - const searchInput = page.getByPlaceholder(/Search/i).first(); - if (await searchInput.isVisible().catch(() => false)) { - await searchInput.fill(volumeName); +async function createVolumeViaApi(page: Page, volumeName: string) { + const response = await page.request.post('/api/environments/0/volumes', { + data: { + name: volumeName, + driver: 'local' } - - const row = page.locator('tbody tr').filter({ hasText: volumeName }).first(); - if (await row.isVisible().catch(() => false)) return row; - await page.waitForTimeout(500); - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + }); + if (!response.ok()) { + throw new Error( + `Failed to create volume ${volumeName}: ${response.status()} ${await response.text()}` + ); } - return page.locator('tbody tr').filter({ hasText: volumeName }).first(); } -async function removeVolumeViaUI(page: Page, volumeName: string) { - if (page.isClosed()) { - return; - } - - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); - - const row = await findVolumeRow(page, volumeName, 4); - if (!(await row.isVisible().catch(() => false))) return; - - await row.locator('a[href*="/volumes/"]').first().click(); - await expect(page).toHaveURL(/\/volumes\/.+/); - await page.locator('button[data-slot="arcane-button"][data-action="remove"]').click(); - await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); - await expect( - page.locator('li[data-sonner-toast][data-type="success"] div[data-title]') - ).toBeVisible(); +async function removeVolumeViaApi(page: Page, volumeName: string) { + await page.request + .delete(`/api/environments/0/volumes/${encodeURIComponent(volumeName)}`) + .catch(() => undefined); } function facetIds(title: string) { @@ -102,7 +102,7 @@ test.describe('Volumes Page', () => { test('Correct Volume Stat Card Counts', async ({ page }) => { await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); await expect(page.getByText(`${volumeCount.total} Total Volumes`)).toBeVisible(); }); @@ -114,7 +114,7 @@ test.describe('Volumes Page', () => { test('Display Volume Filters', async ({ page }) => { await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('load'); const { content } = await ensureFacetOpen(page, 'Usage'); await expect(content.getByRole('option', { name: /In Use\b/i })).toBeVisible(); @@ -125,30 +125,23 @@ test.describe('Volumes Page', () => { const volumeName = `e2e-inspect-volume-${Date.now()}`; try { - await createVolumeViaUI(page, volumeName); - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); - - const row = await findVolumeRow(page, volumeName); - await expect(row).toBeVisible(); - await row.locator('a[href*="/volumes/"]').first().click(); + await createVolumeViaApi(page, volumeName); + await page.goto(`/volumes/${encodeURIComponent(volumeName)}`); + await page.waitForLoadState('load'); await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); await expect(page.getByRole('heading', { level: 1, name: volumeName })).toBeVisible(); } finally { - await removeVolumeViaUI(page, volumeName); + await removeVolumeViaApi(page, volumeName); } }); test('Remove Volume', async ({ page }) => { const volumeName = `test-remove-volume-${Date.now()}`; - await createVolumeViaUI(page, volumeName); - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await createVolumeViaApi(page, volumeName); + await page.goto(`/volumes/${encodeURIComponent(volumeName)}`); + await page.waitForLoadState('load'); - const row = await findVolumeRow(page, volumeName); - await expect(row).toBeVisible(); - await row.locator('a[href*="/volumes/"]').first().click(); await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); await page.locator('button[data-slot="arcane-button"][data-action="remove"]').click(); await page.getByRole('button', { name: 'Remove', exact: true }).last().click(); @@ -162,25 +155,25 @@ test.describe('Volumes Page', () => { const volumeName = `test-volume-${Date.now()}`; try { await createVolumeViaUI(page, volumeName); - await page.goto('/volumes'); - await expect(await findVolumeRow(page, volumeName)).toBeVisible(); + const response = await page.request.get( + `/api/environments/0/volumes/${encodeURIComponent(volumeName)}` + ); + expect(response.ok()).toBe(true); } finally { - await removeVolumeViaUI(page, volumeName); + await removeVolumeViaApi(page, volumeName); } }); test('Display correct volume usage badge', async ({ page }) => { const volumeName = `e2e-badge-volume-${Date.now()}`; try { - await createVolumeViaUI(page, volumeName); - await page.goto('/volumes'); - await page.waitForLoadState('networkidle'); + await createVolumeViaApi(page, volumeName); + await page.goto(`/volumes/${encodeURIComponent(volumeName)}`); + await page.waitForLoadState('load'); - const row = await findVolumeRow(page, volumeName); - await expect(row).toBeVisible(); - await expect(row.getByText('Unused')).toBeVisible(); + await expect(page.getByText('Unused').first()).toBeVisible(); } finally { - await removeVolumeViaUI(page, volumeName); + await removeVolumeViaApi(page, volumeName); } }); }); diff --git a/types/activity/activity.go b/types/activity/activity.go new file mode 100644 index 0000000000..ff7f32b86a --- /dev/null +++ b/types/activity/activity.go @@ -0,0 +1,102 @@ +package activity + +import "time" + +type Status string + +const ( + StatusQueued Status = "queued" + StatusRunning Status = "running" + StatusSuccess Status = "success" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +type Type string + +const ( + TypeImagePull Type = "image_pull" + TypeImageBuild Type = "image_build" + TypeImageUpdateCheck Type = "image_update_check" + TypeProjectPull Type = "project_pull" + TypeProjectBuild Type = "project_build" + TypeProjectDeploy Type = "project_deploy" + TypeProjectRedeploy Type = "project_redeploy" + TypeProjectDown Type = "project_down" + TypeProjectRestart Type = "project_restart" + TypeProjectDestroy Type = "project_destroy" + TypeContainerStart Type = "container_start" + TypeContainerStop Type = "container_stop" + TypeContainerRestart Type = "container_restart" + TypeContainerRedeploy Type = "container_redeploy" + TypeContainerDelete Type = "container_delete" + TypeVulnerabilityScan Type = "vulnerability_scan" + TypeAutoUpdate Type = "auto_update" + TypeSystemPrune Type = "system_prune" + TypeResourceAction Type = "resource_action" +) + +type MessageLevel string + +const ( + MessageLevelInfo MessageLevel = "info" + MessageLevelWarning MessageLevel = "warning" + MessageLevelError MessageLevel = "error" + MessageLevelSuccess MessageLevel = "success" +) + +type Activity struct { + ID string `json:"id"` + EnvironmentID string `json:"environmentId"` + SourceEnvironmentID string `json:"sourceEnvironmentId,omitempty"` + SourceEnvironmentName string `json:"sourceEnvironmentName,omitempty"` + Type Type `json:"type"` + Status Status `json:"status"` + ResourceType *string `json:"resourceType,omitempty"` + ResourceID *string `json:"resourceId,omitempty"` + ResourceName *string `json:"resourceName,omitempty"` + Progress *int `json:"progress,omitempty"` + Step string `json:"step,omitempty"` + LatestMessage string `json:"latestMessage,omitempty"` + StartedBy *StartedBy `json:"startedBy,omitempty"` + StartedAt time.Time `json:"startedAt"` + EndedAt *time.Time `json:"endedAt,omitempty"` + DurationMs *int64 `json:"durationMs,omitempty"` + Error *string `json:"error,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type StartedBy struct { + UserID string `json:"userId,omitempty"` + Username string `json:"username"` + DisplayName string `json:"displayName,omitempty"` +} + +type Message struct { + ID string `json:"id"` + ActivityID string `json:"activityId"` + Level MessageLevel `json:"level"` + Message string `json:"message"` + Payload map[string]any `json:"payload,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type Detail struct { + Activity Activity `json:"activity"` + Messages []Message `json:"messages"` +} + +type StreamEvent struct { + Type string `json:"type"` + ActivityID string `json:"activityId,omitempty"` + Activity *Activity `json:"activity,omitempty"` + Activities []Activity `json:"activities,omitempty"` + Message *Message `json:"message,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type ClearHistoryResult struct { + Deleted int64 `json:"deleted"` +} diff --git a/types/base/response.go b/types/base/response.go index 4998018619..bfdeee8b06 100644 --- a/types/base/response.go +++ b/types/base/response.go @@ -7,7 +7,8 @@ type ErrorResponse struct { // MessageResponse represents a simple message response. type MessageResponse struct { - Message string `json:"message" doc:"Response message"` + Message string `json:"message" doc:"Response message"` + ActivityID *string `json:"activityId,omitempty" doc:"Background activity ID tracking this action"` } // PaginationResponse contains pagination metadata. diff --git a/types/container/container.go b/types/container/container.go index 55452116e2..a86bab3eb2 100644 --- a/types/container/container.go +++ b/types/container/container.go @@ -319,6 +319,11 @@ type ActionResult struct { // // Required: false Errors []string `json:"errors,omitempty"` + + // ActivityID is the background activity that tracked this action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Port represents a port binding for a container. @@ -836,6 +841,11 @@ type Details struct { // // Required: false RedeployDisabled bool `json:"redeployDisabled,omitempty"` + + // ActivityID is the background activity that tracked the action returning these details. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Created represents a newly created container. diff --git a/types/imageupdate/image_update.go b/types/imageupdate/image_update.go index 94ca0808b7..1639f1f669 100644 --- a/types/imageupdate/image_update.go +++ b/types/imageupdate/image_update.go @@ -71,6 +71,11 @@ type Response struct { // // Required: false UsedCredential bool `json:"usedCredential,omitempty"` + + // ActivityID is the background activity that tracked this check. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } type Summary struct { diff --git a/types/network/network.go b/types/network/network.go index 17c1a129c1..eb2c69f0b6 100644 --- a/types/network/network.go +++ b/types/network/network.go @@ -200,6 +200,11 @@ type CreateResponse struct { // // Required: false Warning string `json:"warning,omitempty"` + + // ActivityID is the activity created by the network creation action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // CreateRequest contains the parameters for creating a network. @@ -369,6 +374,11 @@ type PruneReport struct { // // Required: true SpaceReclaimed uint64 `json:"spaceReclaimed"` + + // ActivityID is the activity created by the prune action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // NewSummary creates a Summary from a docker network.Summary, calculating InUse and IsDefault fields. diff --git a/types/project/project.go b/types/project/project.go index 24722fed36..43f62b7555 100644 --- a/types/project/project.go +++ b/types/project/project.go @@ -295,6 +295,11 @@ type CreateReponse struct { // // Required: true UpdatedAt string `json:"updatedAt"` + + // ActivityID is the activity created by the project action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Details contains detailed information about a project. @@ -444,6 +449,11 @@ type Details struct { // // Required: false GitRepositoryURL string `json:"gitRepositoryURL,omitempty"` + + // ActivityID is the activity created by the project action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Destroy is used to destroy a project. diff --git a/types/settings/settings.go b/types/settings/settings.go index ff4aedea93..3affb21efd 100644 --- a/types/settings/settings.go +++ b/types/settings/settings.go @@ -94,6 +94,16 @@ type Update struct { // Required: false EnvironmentHealthInterval *string `json:"environmentHealthInterval,omitempty"` + // ActivityHistoryRetentionDays is the number of days of completed Activity Center history to retain. + // + // Required: false + ActivityHistoryRetentionDays *string `json:"activityHistoryRetentionDays,omitempty"` + + // ActivityHistoryMaxEntries is the maximum completed Activity Center entries to retain per environment. + // + // Required: false + ActivityHistoryMaxEntries *string `json:"activityHistoryMaxEntries,omitempty"` + // DefaultDeployPullPolicy is the default image pull policy used for project deploys. // // Required: false diff --git a/types/system/prune.go b/types/system/prune.go index 630d8b938b..b86858f394 100644 --- a/types/system/prune.go +++ b/types/system/prune.go @@ -295,4 +295,9 @@ type PruneAllResult struct { // // Required: false Errors []string `json:"errors,omitempty"` + + // ActivityID is the background activity that tracked this prune operation. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } diff --git a/types/updater/updater.go b/types/updater/updater.go index 4064dc6cac..e8ee234430 100644 --- a/types/updater/updater.go +++ b/types/updater/updater.go @@ -113,6 +113,11 @@ type Result struct { // // Required: true Items []ResourceResult `json:"items"` + + // ActivityID is the background activity that tracked this update operation. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Status represents the current status of the updater. diff --git a/types/volume/volume.go b/types/volume/volume.go index a2eec24008..5c038364b2 100644 --- a/types/volume/volume.go +++ b/types/volume/volume.go @@ -63,6 +63,11 @@ type Volume struct { // // Required: true Containers []string `json:"containers"` + + // ActivityID is the activity created by a mutating volume action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // UsageCounts contains counts of volumes by usage status. @@ -94,6 +99,11 @@ type PruneReport struct { // // Required: true SpaceReclaimed uint64 `json:"spaceReclaimed"` + + // ActivityID is the activity created by the prune action. + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` } // Create is used to create a new volume. diff --git a/types/vulnerability/vulnerability.go b/types/vulnerability/vulnerability.go index b916f445ed..1c3d68de8f 100644 --- a/types/vulnerability/vulnerability.go +++ b/types/vulnerability/vulnerability.go @@ -184,6 +184,11 @@ type ScanResult struct { // Required: true Status ScanStatus `json:"status"` + // ActivityID is the background activity tracking this scan, if available + // + // Required: false + ActivityID *string `json:"activityId,omitempty"` + // ScanPhase is the current phase of a running scan // (e.g. creating_container, scanning_image, storing_results). // From 02d693b3ed32eae9b3764693d6a9ce9dbd0cf25a Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Wed, 27 May 2026 23:43:08 -0500 Subject: [PATCH 12/40] feat: new account page and small ui tweaks and fixes (#2753) --- backend/api/api.go | 4 - backend/api/api_test.go | 2 - backend/api/handlers/apikeys.go | 137 ++++ backend/api/handlers/auth.go | 124 ++++ backend/api/handlers/fonts.go | 88 --- .../internal/bootstrap/router_bootstrap.go | 3 - .../internal/bootstrap/services_bootstrap.go | 2 - backend/internal/services/api_key_service.go | 21 + backend/internal/services/auth_service.go | 12 + backend/internal/services/font_service.go | 65 -- backend/resources/embed.go | 2 +- backend/resources/fonts/Mona/MonaSans.woff2 | Bin 89904 -> 0 bytes .../resources/fonts/Mona/MonaSansMono.woff2 | Bin 38400 -> 0 bytes cli/internal/types/endpoints.go | 4 - frontend/package.json | 2 + frontend/src/app.css | 130 ++-- .../src/lib/components/locale-picker.svelte | 2 +- .../components/sidebar/sidebar-user.svelte | 121 ++-- .../src/lib/components/sidebar/sidebar.svelte | 32 +- frontend/src/lib/services/api-key-service.ts | 12 + frontend/src/lib/services/user-service.ts | 8 + .../src/routes/(app)/account/+page.svelte | 649 ++++++++++++++++++ .../components/container-stats-cell.svelte | 71 +- .../dashboard-all-environments-view.svelte | 52 +- pnpm-lock.yaml | 16 + 25 files changed, 1187 insertions(+), 372 deletions(-) delete mode 100644 backend/api/handlers/fonts.go delete mode 100644 backend/internal/services/font_service.go delete mode 100644 backend/resources/fonts/Mona/MonaSans.woff2 delete mode 100644 backend/resources/fonts/Mona/MonaSansMono.woff2 create mode 100644 frontend/src/routes/(app)/account/+page.svelte diff --git a/backend/api/api.go b/backend/api/api.go index e78eecca57..40f83d3ec4 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -143,7 +143,6 @@ type Services struct { Oidc *services.OidcService ApiKey *services.ApiKeyService AppImages *services.ApplicationImagesService - Font *services.FontService Project *services.ProjectService Event *services.EventService Activity *services.ActivityService @@ -317,7 +316,6 @@ func registerHandlers(api huma.API, svc *Services) { var oidcSvc *services.OidcService var apiKeySvc *services.ApiKeyService var appImagesSvc *services.ApplicationImagesService - var fontSvc *services.FontService var projectSvc *services.ProjectService var eventSvc *services.EventService var activitySvc *services.ActivityService @@ -357,7 +355,6 @@ func registerHandlers(api huma.API, svc *Services) { oidcSvc = svc.Oidc apiKeySvc = svc.ApiKey appImagesSvc = svc.AppImages - fontSvc = svc.Font projectSvc = svc.Project eventSvc = svc.Event activitySvc = svc.Activity @@ -396,7 +393,6 @@ func registerHandlers(api huma.API, svc *Services) { handlers.RegisterApiKeys(api, apiKeySvc) handlers.RegisterRoles(api, roleSvc) handlers.RegisterAppImages(api, appImagesSvc) - handlers.RegisterFonts(api, fontSvc) handlers.RegisterUsers(api, userSvc, authSvc) handlers.RegisterProjects(api, projectSvc, activitySvc) handlers.RegisterVersion(api, versionSvc) diff --git a/backend/api/api_test.go b/backend/api/api_test.go index 9caba8008d..b366f8e28f 100644 --- a/backend/api/api_test.go +++ b/backend/api/api_test.go @@ -118,8 +118,6 @@ func TestSetupAPIForSpec_PublicRoutesOverrideSecurity(t *testing.T) { {path: "/environments/pair", method: "POST"}, {path: "/version", method: "GET"}, {path: "/app-version", method: "GET"}, - {path: "/fonts/sans", method: "GET"}, - {path: "/fonts/mono", method: "GET"}, } for _, testCase := range testCases { diff --git a/backend/api/handlers/apikeys.go b/backend/api/handlers/apikeys.go index 583336a48a..5f88573fb6 100644 --- a/backend/api/handlers/apikeys.go +++ b/backend/api/handlers/apikeys.go @@ -74,6 +74,10 @@ type DeleteApiKeyOutput struct { Body base.ApiResponse[base.MessageResponse] } +type ListMyApiKeysOutput struct { + Body base.ApiResponse[[]apikey.ApiKey] +} + // RegisterApiKeys registers API key management routes using Huma. func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { h := &ApiKeyHandler{ @@ -149,6 +153,47 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { }, Middlewares: humamw.RequirePermission(api, authz.PermApiKeysDelete), }, h.DeleteApiKey) + + // Self-service endpoints — no admin permission required, scoped to the + // caller's own keys via current-user context. + huma.Register(api, huma.Operation{ + OperationID: "list-my-api-keys", + Method: http.MethodGet, + Path: "/auth/me/api-keys", + Summary: "List my API keys", + Description: "List API keys owned by the current user", + Tags: []string{"API Keys"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.ListMyApiKeys) + + huma.Register(api, huma.Operation{ + OperationID: "create-my-api-key", + Method: http.MethodPost, + Path: "/auth/me/api-keys", + Summary: "Create my API key", + Description: "Create a new API key owned by the current user", + Tags: []string{"API Keys"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.CreateMyApiKey) + + huma.Register(api, huma.Operation{ + OperationID: "delete-my-api-key", + Method: http.MethodDelete, + Path: "/auth/me/api-keys/{id}", + Summary: "Delete my API key", + Description: "Delete one of the current user's own API keys", + Tags: []string{"API Keys"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.DeleteMyApiKey) } // ListApiKeys returns a paginated list of API keys. @@ -295,3 +340,95 @@ func (h *ApiKeyHandler) DeleteApiKey(ctx context.Context, input *DeleteApiKeyInp }, }, nil } + +// ListMyApiKeys lists API keys owned by the current user (self-service). +func (h *ApiKeyHandler) ListMyApiKeys(ctx context.Context, input *struct{}) (*ListMyApiKeysOutput, error) { + if h.apiKeyService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + keys, err := h.apiKeyService.ListApiKeysByUser(ctx, user.ID) + if err != nil { + return nil, huma.Error500InternalServerError((&common.ApiKeyListError{Err: err}).Error()) + } + + return &ListMyApiKeysOutput{ + Body: base.ApiResponse[[]apikey.ApiKey]{ + Success: true, + Data: keys, + }, + }, nil +} + +// CreateMyApiKey creates a new API key owned by the current user (self-service). +func (h *ApiKeyHandler) CreateMyApiKey(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { + if h.apiKeyService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + apiKey, err := h.apiKeyService.CreateApiKey(ctx, user.ID, input.Body) + if err != nil { + if errors.Is(err, services.ErrApiKeyPermissionEscalation) { + return nil, huma.Error403Forbidden(err.Error()) + } + return nil, huma.Error500InternalServerError((&common.ApiKeyCreationError{Err: err}).Error()) + } + + return &CreateApiKeyOutput{ + Body: base.ApiResponse[apikey.ApiKeyCreatedDto]{ + Success: true, + Data: *apiKey, + }, + }, nil +} + +// DeleteMyApiKey deletes one of the current user's API keys, validating +// ownership before removal so the endpoint can't be used to delete other +// users' keys. +func (h *ApiKeyHandler) DeleteMyApiKey(ctx context.Context, input *DeleteApiKeyInput) (*DeleteApiKeyOutput, error) { + if h.apiKeyService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + existing, err := h.apiKeyService.GetApiKey(ctx, input.ID) + if err != nil { + return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) + } + if existing.UserID == nil || *existing.UserID != user.ID { + return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) + } + + if err := h.apiKeyService.DeleteApiKey(ctx, input.ID); err != nil { + if errors.Is(err, services.ErrApiKeyNotFound) { + return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) + } + if errors.Is(err, services.ErrApiKeyProtected) { + return nil, huma.Error403Forbidden("this API key cannot be deleted") + } + return nil, huma.Error500InternalServerError((&common.ApiKeyDeletionError{Err: err}).Error()) + } + + return &DeleteApiKeyOutput{ + Body: base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: base.MessageResponse{ + Message: "API key deleted successfully", + }, + }, + }, nil +} diff --git a/backend/api/handlers/auth.go b/backend/api/handlers/auth.go index b5abec8511..598582f6c3 100644 --- a/backend/api/handlers/auth.go +++ b/backend/api/handlers/auth.go @@ -59,6 +59,22 @@ type ChangePasswordOutput struct { Body base.ApiResponse[base.MessageResponse] } +type LogoutAllOtherSessionsOutput struct { + Body base.ApiResponse[base.MessageResponse] +} + +type UpdateMyProfileInput struct { + Body struct { + DisplayName *string `json:"displayName,omitempty"` + Email *string `json:"email,omitempty"` + Locale *string `json:"locale,omitempty"` + } +} + +type UpdateMyProfileOutput struct { + Body base.ApiResponse[user.User] +} + type GetCurrentUserOutput struct { Body base.ApiResponse[user.User] } @@ -126,6 +142,32 @@ func RegisterAuth(api huma.API, userService *services.UserService, authService * {"ApiKeyAuth": {}}, }, }, h.ChangePassword) + + huma.Register(api, huma.Operation{ + OperationID: "logout-all-other-sessions", + Method: http.MethodPost, + Path: "/auth/sessions/logout-all", + Summary: "Logout all other sessions", + Description: "Revoke every session for the current user except the one making this request", + Tags: []string{"Auth"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.LogoutAllOtherSessions) + + huma.Register(api, huma.Operation{ + OperationID: "update-my-profile", + Method: http.MethodPut, + Path: "/auth/me/profile", + Summary: "Update own profile", + Description: "Update the current user's display name and email. Forbidden for OIDC-managed accounts.", + Tags: []string{"Auth"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.UpdateMyProfile) } // Login authenticates a user and returns tokens. @@ -305,3 +347,85 @@ func (h *AuthHandler) ChangePassword(ctx context.Context, input *ChangePasswordI }, }, nil } + +// LogoutAllOtherSessions revokes every active session for the current user +// except the session making this request. +func (h *AuthHandler) LogoutAllOtherSessions(ctx context.Context, input *struct{}) (*LogoutAllOtherSessionsOutput, error) { + if h.authService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + userModel, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + currentSessionID, _ := humamw.GetCurrentSessionIDFromContext(ctx) + if err := h.authService.LogoutAllOtherSessions(ctx, userModel.ID, currentSessionID); err != nil { + return nil, huma.Error500InternalServerError("failed to revoke sessions: " + err.Error()) + } + + return &LogoutAllOtherSessionsOutput{ + Body: base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: base.MessageResponse{ + Message: "All other sessions signed out", + }, + }, + }, nil +} + +// UpdateMyProfile lets the current user update their own displayName and email. +// OIDC-managed accounts are read-only here. +func (h *AuthHandler) UpdateMyProfile(ctx context.Context, input *UpdateMyProfileInput) (*UpdateMyProfileOutput, error) { + if h.userService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + currentUser, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + isOidcUser := currentUser.OidcSubjectId != nil && *currentUser.OidcSubjectId != "" + touchesIdpFields := input.Body.DisplayName != nil || input.Body.Email != nil + if isOidcUser && touchesIdpFields { + return nil, huma.Error403Forbidden("display name and email are managed by your identity provider") + } + + userModel, err := h.userService.GetUser(ctx, currentUser.ID) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserRetrievalError{Err: err}).Error()) + } + + if input.Body.DisplayName != nil { + userModel.DisplayName = input.Body.DisplayName + } + if input.Body.Email != nil { + normalized, err := normalizeOptionalEmailInternal(input.Body.Email) + if err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + userModel.Email = normalized + } + if input.Body.Locale != nil { + userModel.Locale = input.Body.Locale + } + + updated, err := h.userService.UpdateUser(ctx, userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserUpdateError{Err: err}).Error()) + } + + out, err := h.userService.ToUserResponseDto(ctx, *updated) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) + } + + return &UpdateMyProfileOutput{ + Body: base.ApiResponse[user.User]{ + Success: true, + Data: out, + }, + }, nil +} diff --git a/backend/api/handlers/fonts.go b/backend/api/handlers/fonts.go deleted file mode 100644 index 137ae75004..0000000000 --- a/backend/api/handlers/fonts.go +++ /dev/null @@ -1,88 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - - "github.com/danielgtaylor/huma/v2" - "github.com/getarcaneapp/arcane/backend/internal/services" -) - -// FontsHandler provides Huma-based font endpoints. -type FontsHandler struct { - fontService *services.FontService -} - -// --- Huma Input/Output Wrappers --- - -type GetFontOutput struct { - ContentType string `header:"Content-Type"` - CacheControl string `header:"Cache-Control"` - Body []byte -} - -// RegisterFonts registers font routes using Huma. -func RegisterFonts(api huma.API, fontService *services.FontService) { - h := &FontsHandler{ - fontService: fontService, - } - - huma.Register(api, huma.Operation{ - OperationID: "get-sans-font", - Method: http.MethodGet, - Path: "/fonts/sans", - Summary: "Get sans-serif font", - Description: "Get the application sans-serif font (Mona Sans)", - Tags: []string{"Fonts"}, - Security: []map[string][]string{}, // Public endpoint - }, h.GetSansFont) - - huma.Register(api, huma.Operation{ - OperationID: "get-mono-font", - Method: http.MethodGet, - Path: "/fonts/mono", - Summary: "Get monospace font", - Description: "Get the application monospace font (Mona Sans Mono)", - Tags: []string{"Fonts"}, - Security: []map[string][]string{}, // Public endpoint - }, h.GetMonoFont) -} - -// GetSansFont returns the sans-serif font. -func (h *FontsHandler) GetSansFont(ctx context.Context, input *struct{}) (*GetFontOutput, error) { - if h.fontService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - data, mimeType, err := h.fontService.GetSansFont() - if err != nil { - return nil, huma.Error404NotFound("font not found") - } - - return h.createFontResponse(data, mimeType), nil -} - -// GetMonoFont returns the monospace font. -func (h *FontsHandler) GetMonoFont(ctx context.Context, input *struct{}) (*GetFontOutput, error) { - if h.fontService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - data, mimeType, err := h.fontService.GetMonoFont() - if err != nil { - return nil, huma.Error404NotFound("font not found") - } - - return h.createFontResponse(data, mimeType), nil -} - -func (h *FontsHandler) createFontResponse(data []byte, mimeType string) *GetFontOutput { - // Cache for 1 year, immutable - cacheControl := "public, max-age=31536000, immutable" - - return &GetFontOutput{ - ContentType: mimeType, - CacheControl: cacheControl, - Body: data, - } -} diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index e0177ee2c5..726b7ee0e8 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -37,8 +37,6 @@ var loggerSkipPatterns = []string{ "GET /api/environments/*/ws/system/stats", "GET /_app/*", "GET /img", - "GET /api/fonts/sans", - "GET /api/fonts/mono", "GET /api/health", "HEAD /api/health", // Static branding / PWA assets — browsers re-request these frequently @@ -191,7 +189,6 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) Oidc: appServices.Oidc, ApiKey: appServices.ApiKey, AppImages: appServices.AppImages, - Font: appServices.Font, Project: appServices.Project, Event: appServices.Event, Activity: appServices.Activity, diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index fb72726743..ec9899c10a 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -47,7 +47,6 @@ type Services struct { GitRepository *services.GitRepositoryService GitOpsSync *services.GitOpsSyncService Webhook *services.WebhookService - Font *services.FontService Vulnerability *services.VulnerabilityService Dashboard *services.DashboardService Role *services.RoleService @@ -67,7 +66,6 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.SettingsSearch = services.NewSettingsSearchService() svcs.CustomizeSearch = services.NewCustomizeSearchService() svcs.AppImages = services.NewApplicationImagesService(resources.FS, svcs.Settings) - svcs.Font = services.NewFontService(resources.FS) dockerClient := services.NewDockerClientService(ctx, db, cfg, svcs.Settings) svcs.Docker = dockerClient svcs.Role = services.NewRoleService(db) diff --git a/backend/internal/services/api_key_service.go b/backend/internal/services/api_key_service.go index 183d5977d4..57d6028574 100644 --- a/backend/internal/services/api_key_service.go +++ b/backend/internal/services/api_key_service.go @@ -481,6 +481,27 @@ func (s *ApiKeyService) ListApiKeys(ctx context.Context, params pagination.Query return result, paginationResp, nil } +// ListApiKeysByUser returns every non-static, non-bootstrap API key owned by +// userID. Used by the self-service personal-keys flow. +func (s *ApiKeyService) ListApiKeysByUser(ctx context.Context, userID string) ([]apikey.ApiKey, error) { + var apiKeys []models.ApiKey + if err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC"). + Find(&apiKeys).Error; err != nil { + return nil, fmt.Errorf("failed to list user api keys: %w", err) + } + + result := make([]apikey.ApiKey, 0, len(apiKeys)) + for i := range apiKeys { + if isStaticAPIKeyInternal(apiKeys[i]) || isEnvironmentBootstrapKeyInternal(apiKeys[i]) { + continue + } + result = append(result, s.toAPIKeyDTOWithPermissionsInternal(ctx, &apiKeys[i])) + } + return result, nil +} + func (s *ApiKeyService) UpdateApiKey(ctx context.Context, callerUserID, id string, req apikey.UpdateApiKey) (*apikey.ApiKey, error) { var ak models.ApiKey if err := s.db.WithContext(ctx).Where("id = ?", id).First(&ak).Error; err != nil { diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 2015f18713..258b412626 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -810,6 +810,18 @@ func (s *AuthService) RevokeSession(ctx context.Context, sessionID string) error return s.sessionService.RevokeSession(ctx, sessionID) } +// LogoutAllOtherSessions revokes every active session for userID except +// currentSessionID, so the caller stays signed in on their current device. +func (s *AuthService) LogoutAllOtherSessions(ctx context.Context, userID, currentSessionID string) error { + if s.sessionService == nil { + return nil + } + s.tokenCache.DeleteFunc(func(_ string, e verifiedTokenEntry) bool { + return e.User.ID == userID && e.SessionID != currentSessionID + }) + return s.sessionService.RevokeAllUserSessionsExcept(ctx, userID, currentSessionID) +} + func (s *AuthService) createSessionAndTokensInternal(ctx context.Context, user *models.User, meta auth.SessionMeta) (*TokenPair, error) { if s.sessionService == nil { return nil, &common.SessionServiceUnavailableError{} diff --git a/backend/internal/services/font_service.go b/backend/internal/services/font_service.go deleted file mode 100644 index 547bfe4e2d..0000000000 --- a/backend/internal/services/font_service.go +++ /dev/null @@ -1,65 +0,0 @@ -package services - -import ( - "embed" - "errors" - "fmt" - "io/fs" - "mime" - "path/filepath" - "strings" -) - -type FontService struct { - fs embed.FS -} - -func NewFontService(embeddedFS embed.FS) *FontService { - return &FontService{ - fs: embeddedFS, - } -} - -func (s *FontService) GetSansFont() ([]byte, string, error) { - return s.GetFont("Mona/MonaSans.woff2") -} - -func (s *FontService) GetMonoFont() ([]byte, string, error) { - return s.GetFont("Mona/MonaSansMono.woff2") -} - -func (s *FontService) GetFont(fontPath string) ([]byte, string, error) { - // Prevent directory traversal - if strings.Contains(fontPath, "..") { - return nil, "", fmt.Errorf("invalid font path") - } - - // The fonts are located in "fonts" directory in the embedded FS - fullPath := filepath.Join("fonts", fontPath) - - data, err := s.fs.ReadFile(fullPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, "", fmt.Errorf("font not found") - } - return nil, "", err - } - - ext := filepath.Ext(fontPath) - mimeType := mime.TypeByExtension(ext) - if mimeType == "" { - // Fallback for common font types if mime package doesn't detect them - switch strings.ToLower(ext) { - case ".woff2": - mimeType = "font/woff2" - case ".ttf": - mimeType = "font/ttf" - case ".otf": - mimeType = "font/otf" - default: - mimeType = "application/octet-stream" - } - } - - return data, mimeType, nil -} diff --git a/backend/resources/embed.go b/backend/resources/embed.go index d2d103ac6b..48d373839d 100644 --- a/backend/resources/embed.go +++ b/backend/resources/embed.go @@ -4,5 +4,5 @@ import "embed" // Embedded file systems for the project -//go:embed migrations images email-templates fonts +//go:embed migrations images email-templates var FS embed.FS diff --git a/backend/resources/fonts/Mona/MonaSans.woff2 b/backend/resources/fonts/Mona/MonaSans.woff2 deleted file mode 100644 index 904b96b4bc5c56337386ecdb27650fddb3a766dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89904 zcmV(^K-Ir@Pew8T0RR910beix5C8xG0|+Pp0ba`h0RR9100000000000000000000 z0000Qmn0k79vq+!24Fu^R6$fa0E`?jI0}NC0D`0X7081DH4jAO(z6 z2e&I*mg|xLItA2eVXH#erTgc2+IGK(P=veWfH=l>6tHxS(Ip!&g?g>;SHX@A0?q?q zNUFb?+5i9l|0|Nk7^_*V*oyNI4f7a5aYD6CxSm=ov?-GwpE z(3}?NB-R#cTNS&Ab`24?z9z(!j^1{ph%Re3tSLg>H;*9byEYa}cT;mvlWw4cJ8zsQ z9*2s=DeHysio)cw~72|TnR-}bQMOhXYnR_Kg5N|(~KwOJS!R$ z29~Egvhr9~24YrG5=K+l+KNkebw(9dvm^&hL_#vlTf;25{L>^U>QY3LU$JXC?urLC z4L$;~;!AA7_fR{-%Pvw9|L5$D!iRr?JmGtv4$g>+W<`aczH|63SXgC@u+w{(Cs@Q1 z_zNn})F+?*^ZYi?eGfjgjA1m!N?nwzEMwI!A`3x~FbgLP&^qsq zS1}g!ix;os@p~~|b-$R(rOAnj7fTzKqGzUJn5;2F{G5L7p5|H1A&3?z6snWb&h>jI z<^@*T&N`LO6KYr~uv)k_3 zhkwuCD~U+b_ym_2AQH7%EDVp^{+|uDv!bPhmQoyJ#3*GONLgSY>b=*(y2j1&{fG!( zhwnyvg&$~}|4;-01q4j$?l@=_Flrk(w^rBX8k5n+xWqDL%1G9o^*KMV%MhJfYf`2{V4Y{~fvZfW&;0N-o&PFOS}10<#2fd!%UzO7 zE*HDFHdamQu#TDj0W*hf-*o#f`=7b;XU@jY)0O?7hKjl+cXd^R)8kAg&~7$MJbq&{ z+b`e5gO&_DIMSKTf>y8s6YFHaBtC~o_f(YMbJNkTePQs00jfu+@=yH7ey=AWR2(VN z;R3#&5>g12t*~{X<*0W&vKx(q{h7x5XYYVvsYkeMV zwjx&XN~Vb?v63j6wytBc>oX3Vjp(0~cqJNq{_kboO!5n*bcg16wko^EX7*8p(I^GM z6eYLRw_br2Tr6g#&KcR~c5dDw}(spkAOOKH#OfW~+8jnw??v z-ZY4=fCd^uf6KluNJR9fZ`Jxg5~3+p-t8lX%)ssyXYW%pvXX{<;_@XfD+Y@W3o}ex zb(dsDY}r}@oKI$X7S~eQ8=<|3k6J z1SxF;MXCFhddx>_IP)QmNw&F{syWX3L}r1Nra&eVB$WVIQUXP?6DYEkK#A%s6s2a- zXy~V!YOc(Ll^V#0n!DIIb59qGz>>6$;X(pjl6 zo&CyF8l#y2%z^^(|8sBl&Lk-*;1rYw((GzHLdu|8^{cA?p0_}1-C9?Bey!EpP|vi4 z3&}7~ry(CSX7}S-it2laSi-AW^xE-AY*0Gz{AcQCDQ?xPe&G17nUb{DL>t7_4RB^c zuHlB(}KGgr|wA%TW zXMueHSYhu)>?-+>NAhSit6c|eC%gnsvILTBuIsLE&i^u( z$vEhKGR8#8ltkTwY__W`V*l&UAckhQh8Y>_Vg{K5&LQK1@Gglj$-}c!HCutpQ=*f^ z$MD@*l|xerol15A#4v_Z#1}&?!{~oDY4bogD>0VFDy@dnLmlU?=Y05=ItZr);1B@s04TF)xFDT(Ay?@eX3AG9vmaMr!+?D^2yH0}DqDIHMFtN$)qQmrzR z+bxs(|Knc=P2~{WX1Q2o2NB3#DCq!y@^F8(q#dxkF+-rtYDqd`YgOwjDDUI_RWp9B zV|D#pMJvs#>$FNBw^^Fh21&g)F)ER2g>Oxdqf^x`|41 zmem*vab>hBpWSyxHfY@@nCz6EeGtWVX| zaHQY;JipDt#($ms*jx(wTVS4c$C|6HouX+cFlO=vE^FSK+N*RO2 z$h#JSLrJZ+5{8;;8EA(VESoOwx{!Pn9^Ef;a0o^LF{lDg$z&7_tStH|4C6xNal zYw%@NYxs`h?yk+@w#z!1iF2$onUzj)oJ?#{Nw+Pi36#7;Qm(D=9HaGEd0VtX{Mb5` zRh-FY3Ol$DwdSOsI8DyrQTo?(Y4ay-d(4)A@j8=KW<(8P{*l@Bouf0#W$5*f-CI>_ zSd|{28FkLBMadzQS}zEgbo!}QtwhM+ySrA7!cYgKquR}fl}M_Z4+>Xblmql zs@#JnaxShkQymVzn3gFtOv@UgHaYzwG1o(=WfZS1Nslb?xSAz4)%$krx>7S;PXBvm zMXQcc)p}SM3oz+Qgnx0;(Z}$K0<3`n&`+R-<V!;x$KpS0YV4>wiKd_=sxY?yTpX^K=QokPSeG^;r+enYa&-N?PKG;vGV`xzu z6cK=wKI5sRdqk3{DZ7I_f?j^p(EJ5BVj3R5F^! z@|%E34r=pvfU^A^aNPI~@W;LbqOP}#{UWe;Kzi~WP#!*ts<1Xm9SuZ417tdQDBlix zfS1(zoRGqkIz>({fOrVpXVaO*b<)^N=HwQy_(A1HPe<})J=YS&qr83b*CbN-&rLw! z#mYd3PL}}SkpgjB(=NRO8oX8je5O(a9Ic>ahz3PQP^8!jB$4f!Rd-M>I^b5ut`a{$ z1HOyW_B|<6p57`{DhuvLRWn3x^hvO%?;+HR*?>vuU^10`e0RacFvAho-!{_j|H-7JppXGNy|7SF5Y_5I50oPK|=(_40r9O|sFtkf#_a5w3R9DtL z(EAK3p7m56Lh0~mFm@>089xe)ML$cORYMf?h;ii6pjZJ)=7H3km`*NLKPJsya^ zX_Be=xaVLP-fZ@7Rs|D~>WH;k9hs=uW(s!_EMp z)=n?5W$o~r|1*;aBfR_)sN1i%yi<2yX>sofGZ+w?2gPQ?tYh6A?&FyNtQ-EP1Rf@C z!6r6cpU9uFSu{UmWhSnjBQ z)jidF+F0?O>e8#;>A2H%XXMWGJ*#^zFyGm{b8Nm#O4VVI&%B3Q@m*Hj`L57inY$Wy z&7gO?8_?rk(Y@xo33u~01{HV9F(Nqb;_vpT$L`LS-7DL%aJsvF_loPSg4LU$tfdVR zUrr`e;3$kKdeHQ63)}+_vMcAClV}B$aV4!S-7n&o0J>+o5Alk{;Km#g#b&XK>(^Bl zI;P`2FbuPD2+|T4QhOV@xU^sj7_ij#z^Y+N$Mr(v9TBgSVkKNy95Q zv>P5k$2>+k1JCFIxFhlzsj$F`$-~ke<%9y1u2w`KkNPVKBMj7L=A)H638b@1QAR}_ zvkP{8A0jdc<%Y4XLO*pI5i$|3rJ@ugKBZ>;R;MDr%za67vVF|@;8g9@oMD&#Lx@JY z&0Qh%V@&a*M7`cBlA@PQvJDf5OkSDapJNFoNJC{QsfDxzt6Cl4OJMABV zEH^Y72Pm->@+(}Uppd-ubfpg}6bMkHjZvnGQ6UN>iijK=7sP@cF-D5fv0f6Zuti$$ z%I0PIw=B{JS>VDkn6s^^&V0{7pxTI=O;egAGoN|Ad39c`ulz01OQkox2D$z)H`Gp)47bIq zTr`rui&($@h`%nneYc-PVpHD{MRM6TX{44Jo21cnwjJpSg31_ zaWG16uUFKs*K%{!r^`xoNV_q}XwX|sA2tU!cU1vpjok>SJ zoYe^D&gSEwv)#nzO?vPh(yjd6MxYY~YnSvaGHFCamsRz%YO}pf`L@$(NP~09NJcDd zvT3i6DCRDYq7(^_$T-eun70BtGq0p;_0nrjliZY>9Fy9Z-_IBemPMsl*jM7B+_0F1 z5zgL@TyA%=UGhc$o#j1!oGAj}s_PLuOL58765QA+As1lyAJ{;{)susY8$4cAF<1`Xekosyay@&3=jZ%*b*# zTf!!{3{?j?XxRQYfE^#!fQw`287RIW4otzFwRI59({y-tFy`T`vI@!L>GNXvI@tA6 zyR0fM>)@wX^W3;Gg%jH1<^!+~Q`qNG++NatB(N#`Bgxu9j$mHV}G=Ky|otHPvmj$Bd(FEQ{4B#$G=O(C|NwB zw>JIZ!+6|keNi_)d0|=hdv+i#>Isgkgr-n#-kUjMqlBY0zs-$HnoQilX4V6eh!p z-y{1@%hvJp@Fl1{%f6yn2C)xT?e|VZUMl6RT5bNo>Z+s2=4C_gbE9wmQEz(52yV9> z;vc&^EjO6*b@TGsSAM=56ll!62%XrP6kCB<9Tf#Di$59o%Y)xtcsYJoS(W|Xt=3^5*65@i zwYuoDKDcgQ%MnsaTQR;m?~3GEfx1G#9a@vb^sED)G}O1NVF-$2N4aQU8(WE~a;J(Q+NG*?j zSU{p;neMN%`eu;S-m6kI+jUh%F? zOYY}F@@FxbTECL3S{l24)L6Z6hkbULQ(RreWDZo-^{e^$NN9-6yLZ?j%i|;Oln_Km zEX)s_iMS%EUq7uw?Y&2XWd$V1V8~v9EprFc&7zR0HuvNfuul5NsEjhf_1EFieB1HpKd9BMJCY(GP?Lq zpO^BrJHv&uW~D1!gq|qwE5DhQ!xM2>`U+{nBByOYL)otZ|Lb#S?$nhJ?|C>Sy4STR z*v$3A`oWk=$%<3|2~5OpWAM_U7m-KWk!ioE;Eio79ej-|BMX1hORnncl2x<5BI+gi zLfR8(`W21kzSs39o&;0ci~BjLYK%YxUaeLH1flW4U^xtiGDD#4gtnSXdq|?^CEBWx z-ckM(aw`~%-U$qaFlC)%8tC`GUwHE zUOMMxb6z#)J?6Y}&dZB?>niHu%{(hxp94SM&)orxG#ndk@aywMF8Z_@VD~75znmq^G|EB4o%~qSG9?tA6H6&#dARbOC z9tjnXrWKE66z8&u$8(D(3Wz0(dTLoH-2YI#(33;jR?&OuZFvH~KPSMTkY6swF7hLI zWVjc@0LZ2ACHYjUOgZXep6zFN?ynMrneT6%zv1T%{@xcV$ zjoXAmjoT5ot8b6pUb}sHCwvEW$L3DJot!%_dj`53l6T4F)vz40Z`BC<${gev0Kj9^ z+^B&Yfv|@tZFhSOJnkFK^{vsjR$rfe8@vgjgCVRh}y1mtaAH!fO73!@`u<1NAYGI7`wtyRF==dNqjm`*KdPH|M_@~uD9NK?}JVs zebS{{kI%mN>YML==+&p+fI&lsjTr5tw=*;>JR&kGIwm$QKB0bthK(9GY1%9?DLExI zIVH7v`4m^FnNru#)Y3L>#;lI6lM0GT$}0Y&dtzcB-F9pFJUcriVIo8+Ql~-lEw8`c zd+`<^Ux}ycG;8t3o>Qe+N!Ps?Vhj;~B8euJY-$;1npxJxlvrwoJ?=>rRW{RX`(?`Q zf|<>H7PFkytY^B3OLZPso0dvHs-^ymZMle1AS7Wuma z#pLj!MI@F~vI>rJF?SL^Po9=3X~Kq!ucCYM3&NF{FPLjU`+J!hljSDLC22PVE|}G} z>NA0glHbL2xxG?jK`|r3`tAPK<=k0+#FKs>_O;mbut^4a$jwgEkDQ~un`)a^Pze=KR@hdgsozf>{1ZGGxh- zr$B`ow48Y81KM<86GOKdNB&#UF{2*@zI~5vJ9h2Mkt<(;BE?EL{a41tZMh+FFg%Vp z8}gSMfYPJ^U|;QwQ>ltQPKUS=Rq ztb%;C2J+M-$TPn|o|^*cZXMM7F0f8E3hGpopiVan>S7z9YSs*@QEi|e_Z`%eet>#f zFQ{krA?SKv>j(9s0Z{4ffX#o1iA;6cU<(+}M5i_#umz5HVnYuLwxID%eD|jdw&3wk zVwmZHO+Vx$r#^kKg-l>l)0hF+LMJ%sY0eOAVH29nv}Odh@Ci?L4`vLiY!#r5oiAwP z=8s@83s?|n(-#U9Mv-dP(y41J8^PFPZ`qVXrIM<7By{avGlSaj&(c-9?g1cN<#ND) zeZtHD=rnrK^d>OfGkDCyfUFm@`hocEUdw@iF6{pU$(H|*3bb{ho-6@Bb1}nivEKlt zU_*%%&q3$vqd@QbR&$l73A5g|GS>rw)V8xkJd6Tzi-f=ixmvr|x-V-{oU4*USr|XQB&) zXKS`;yLM=I?In8)NkCR0n~*xB0eOdfLg^gYah=?G1l^0lFdE}r%{AS&dlZ|A&BYhs z_4tkFDIJ!{MSS%2UAC>n06;(rdbmTqwq#4gAU!CLR7Pqd4Weq)1^wlq@*7MLAmE(JwW+l&F(Aa)&FaIyk?L)JV zrxofL0H&Wh(nn|YJE;b$l%mv20K+%q8;Ts~9$dtjecRvJQcYi0dHVp_2Ee6c(u=(O zu8LX$fc@^~x48eu!Uq8ShO4=kL5V-~(6jdV=B9u4jO-ZtVuR&$RUYlvyPyzs?RGF* z_-RF6(r!ve4&>Us=%dGlGv$QC%2&EKV^n^+AdlD#-c+Q?P(pKE28>v+;waKQxG@r> z$x^LGgIC&@H0LF6U{QwvX%Uc!Zo?PW35%E7TxMq9O25AXzheu!nH)GFH6Nl8=jQ z927%Ph#M+C%JIS`O*={G1nDG2BP7*i=%>IiCB~tcrphE0W~ng`%{mRXX|YL@Lpnlp z5R$!!oW9Umtw2+cH0$Vv zH&u9DIlnAD4u?#hHVJV~Pi|3)OHfd(!r~MaPcBY&J&;m>%)$-{XI#crT+YSJxWz4R zZR=az{N}c?KU>-g%P{II^0It&6}sggooh-3)QWg0rg1@~kXR;S*`#vF<*#sLPqaF5;POzof+ROxMsjTBOV#^%!FHp{Ie36ji77=XD1+QB_(=P zvL~f@R+{IfE9;@BrNZ}AOHaU&sd(9(Ql&|hEJczoTW!;!omaJa5h(V9M==6x-+%xO z2%`f)`Mz&8R{;+I7!AOD06-Z_8TrB0)WT= zng~)IPA+ySnHGV`bGOmf(Q_ej_7c;lyzoBKo+vD&7YB9qKF}>N)&4iuY2cJf4sk6AE)ML3n<*Y4OZ0Y%K*yN zLKVLcZo?cO^K*=W^iL&WBA9gjh=P^R8r7q1W|BGs)s}@v8d`Q(&I;AD8QSQ}PkW zV8|g_v`hlyX5D$--P81K^;*1vg^iv=9X3|G=?oh*AN0YE@|@RQX*3Rx15yflUF)L- z`1lv+KBx2Dq}H7b%FakCtQQP*tp zrfAz$n`v2QfU*n_l%S~}1h9qB?AF0%O6Q_8Sbj+uT^BH1ikgD38pJYaqK=DfA_F@7 z6YE(-O~)3Upgt@$UER-#KO{ZCzeZl_P7!n@H#XXei@S|XJ6fix_@?`?LOaK2~6lxDT^OV1qhPG5G@8mP1ta?9Ilu5&w?>7HwaqhDHwqR zDZn-5`zgp{Lw1;wGDB(j2hFgFHUHT_v4tklMLAb0)W!jV8BEY1s~uQVbl92Kjf3*! z-Xh`>J<5q9Fz-&}Uw~8Bs^{4q9*2jTDTfPw7vMFDk_Y)R8oEmH57* ztYTFubrIESTis!|#M*l{VHiXPWtB)bvp#l)O#@AmIAZ0z?e+%wep3F9%-y+`2$=($ zW-!($xsE#-?|`bd%3@0(w+*9kZy~*S#ffBB5jEdMX9OPEVh9(q(#I*&#SHHNtr^nj z;+^6E9B?W07}ea<*O7-0TiMcg585OP(Pd6qHv4WE zZk?!%+W4Gterv&Wp(kv5cCfHbF`E8Gw#`DAn=UqdnA(74iKV&l$2T zxxx(Axl&6LDgimGRf052T`OuZCofO6D$HB&Bow&6h4p$^K65Bd15M23m^J9|%CnOT zNpOX_&H@=VlW(8E#pv&26&qvFoLjoGrMzv$8PjH*;iu58L}99^Q3~P+{{3aB5;D|* zQ%fQchdN}T;*+gop~=zocBRv*?!1*_XKn$Jt9MEDQj3GZNj~%XS~lm=ICG5+1-?cB z;%~rhQ8_lq#r!FP82k@$z;UgHds0t7Z^6q1p~IbT^K-(td&iJ zkUKJT)(e9-yXX0AIt^M}eH)J|*IrrWMXX$Mek1eS75N+>BctmdaVd0hdLPk`1Y zets1^D#0T_Q~klvgI}R-)#2qdY9l>ulS4;2=74Hu0q)Hvx}ZW^%&INt{A%^%SJ)E| z!9iFU4Yx)e+aeyY^?yGHaMT1F3>qcH0?O4zYb}r5D^Eo?(WfK3QyW?9HUhtfwT6gl z8^!jDo_|urmN)+N5YiEodFA29&kk3y-*L=(*C5{j7(;qJHjzu1t6PEE z#%LHUR-gxMGC>$70#Q5c(FjkoF^kp)xim^0rJ>E z3St-KOu5XYe77mIzN=dUJ1!>+)}7ZYQ`0PGci7DmO}wD@qt-RU6ghpoF9=}>rw zKXrgF%TFw8IwBGQbM_wKbUc_jRd%`b1St;sf~8&|dDc1>uK;CR^k#22SWgMyv`oSH zU2rQ8V*BJa^B*FeA-PmA1{bIny`LTJ`y)M^!{6*kJwcpxwdt8qd6$>sMi}?VS3yNz zWLMgt%#h)cZ`F(?n*B|--*v2;Ba865va}QJx;vxCIDS6!`jetirF^hdtsDvC?afPI zrok2CvSX%fg5Z|a^~%%!zcTi$)pR{TfK@Tb3zlLqiVAIq4-O87`qf4|Fm?sHEyt}$ zj~lhp*npFwiXXQ92|n&b4-yccxHrpV9@vo%4X5A=pDbwcHXXHHDKGr^c2#Bw(TO@b zO}aI(3jrf0Jj+k>4EkAIw4A)?wwmH1BHP6^&g+a0$Acz&N=yBWNdUFn5c*BwM z02?)C-uiFo3x6D=@$B3OY9LH;Mq-aLQuP&ZZ~87=b`X7GtgLw{v6ImHI&sFxM9wl# zj@X7S)uMdaD3K44A!7&f7ywUPkC63cB71Q5WA3RMW9(7G6G=qVxIQ|FC6$7R&w<1V zow6SMqj(Pmh=LDwM6(c~pR-%Rhcfy|HVyAk4BI5B+Q#lydLdmkw|_*K(R0qAKvt%4 z7aOW^&-1c3Mmi<>2ngs49C~eV1!zW8}=8odMU~?P*W(?XHtd zMzXQ+Z6bq-K;sF8(l4Hn?^KCh>Jcn9?QRmC`%74=z&|RHg=fPn9B}QU$%Nbd+|Z_p zh=n?WZ{aFWP$B~MX9EFyd;1ghFU^0xL;sLXqTJvwDd=9zomP(LOU)eg05vij#FB^K zFjeSL1%A?!AH2X;VRMMi0R5$eq6_CASbAB{*}GF5)>CqFg%fvH%V0Fesbi6DIj2zA z@q-JGOTwn`7f^(%SK723$bJ$2hC|TPRP$hYS`b%B{qX7TN_*G9AdkUcwZ9P_o~W$c z(GwwYiZ&nm5B~HkQALr`MEay2&ghdpPnfmAR-IfXX&Okv>w$ik<~@2#n3!Oy~tDXTuA%04#H;W}ggo;htRv-yqOIr_F^JD&oli}^gBsBu}z8GSB9 z)o&<4lM%2119}n3&LV1@2p8+_=9K6t-&W$xC698097-qxV|$}iL6L;LB+h~2dIVX| zF$bQWm>LQhhd@~xhMNRRG%+T62}3qP>SY(6uAUM1OwF?LYm^C{wrIO@OAj-S7M{sd z@IC?3-u>lqYczQFOw(fFw&l}VrM0Vt^qnC1;J1Fzmt^5lzkTevoecfs!W~^x|BY#9 z=f_yUD1A}3E{qbOHqe6mhW0R5j{=_aHZ})PbY)8*cdXpJBr~>NP~4wz0ubDdf`if$ zKOdQf!Y90(&%829K`Gofi~PVlxme%yRhw;~UY^PgFQwfMy(!x;vZ!b!3oX5#gXU1j zLde!=83Y)x%gBG60#Y4dj|B>PTUqiv7@ktWNr+k(qT1}TD+%YGWkX_TA=|E3-t0}b z^o1l#*)4!sjy+<5f0>bCtlOoObRxyfDVssGGZ^+FT!|v`h$?k5oE}Xx>2%IlQ_CerXbpq5mB?5~T+~|{p|HTixMnXNN?Z^TDOiMx{7q1T7PI?jb}O@zWsi?bi;YVAN_!AN;~;Te zhEKTWFg*iMaalX28&hkEcTSx4y_~|jXbi||OlwbQY`_UxOes_Y36jGxkR zLT;^h3<#sP&i)vS9?Aln*fZPpC1zwQTGMo~q^SIvvpo7bJ14wVZ~mjk6L%2OrLv0M z^=+)AL-mW0SH;BX^koAuq%k=$U;_!Q@wVQh5RuCW{S-_lH$^7aNUN=jE|s7YVaN|O z)jlmwBXafclo5s}k=Ebv_Zb<7H3Pgh6>L1mkcusZvXcpm1zsJj8DBO35GR;7%3X3tfQ`wKGC( zX&CS6CH}nC`o~;UbSCj|PPCe!TqZvu8l@U^O=FxwZNS(s4$f!6A zsJpm6xif#zfdmmq_GCIal`XcJ9(yvu+Z-;l%0QBkalMu(fHAa-(Er&)_@6ot{=}WF z-<(Jz-G8V7H)5YdE9*Dvf~$S#urrIMmz%YTC1y6^aYZTrKIV4Z?SiqS*Ynn*G|xTJ^%$=Zn_sSIFQK*Zc-I;wAjsh3p6ZBwg}KImtK(+=#dEp$pGs zwCndVvw0!Jvr82Gs`sukNC0Ug(7z1NFeC#*x*aE1J#|Z ziNQL4>m`9Ca3eTVvVZ@_r|`eqdhyx5-`ztGfZLgD za3_>+AKKK{cbc6S*zv%o-p^W>2^&H)d6{{Dl^5{qa`-RWVwo2TCBIQ9Fm0kA3WIldh0YB}wWZ+c01u1pd z68!ubyj7IONP~aKF1W8w9oaS#lD&d*o+uWbC+Vw{1#ZOl=F&_#x7=*L&uX6G6rm!8 zWcs6Lp39o!H{WzWdUBRWE*Pq2a!;~n65?hnh(6y?;ZN)JU)t}2NHU`~Z~(Xw0=IT9 zhC%TUH~n>}0bg`Lo8dNPqs!8p;8D{-=1?jec6-w9P|uG!unX#PeXPzM^j5;zOp9`; zcyO3U?C|f*@@00tMoX2%2$be&T^DkkksRisavoIH4Kh7DJ-f0(rPmtSsI&p8*btZe zdU`xcJ+brT`iH_{)%3aYAzQJG>C-b_DitpoL?7ik-IfE=C$dj6+K&&zhy6b1BDEPa zum7nR>vJ*ktS;D%uOFDkKNa#L{?QFa16+Dq4R2C+ZH@gY8#S+9Xmu7=nvY5+R&C#z zv~M9f-`8?+d?<|jTL)?^DI5q#TkFp-ecnQTjiqfxB{osu6@Cb-2q`pYjHi zR0@4FWo{t>wt+b4o%Go=)4}W0se?jP2aPfyHuY7wo7{=-36cXQRHRe@yef-wV6#KfQ{){O}f3sz*w&%;Uzv@vdL8?G`5dVTR za}|BJKcRzFh{+)GF;s-%I*$XlB0j?|&LJrrV5+Q?;UnaSB^}}_SjC+_;$C_Ru%DHu zv|+h44Gqs!*5X(UdV@`Y%~L+SZX1F7Mq309_OLXnH^cLpXK}ASt@bsUc&VL+_EQ`8 zCmFB=%CNTM@n{#Th)wQiY0+PCJ0{IWV@NNqV71C}1d1YO*)McMD2}q;&scAj{TWsp z;YfSwbN*++?C(17$FgSSyoXfGq)d>WQe59l3rw9h4dJCb-H(>ss_DGZ8ltCX%8g7n zOeN;3Q^|#3UNveT9kqi6P=YlbuSeA_BA6|UN)~#ZcYX%P<{pWff|~RG$+!J<*>8PA z$-Z*B>ha|p{h7Nb=eZa9PC&x(MeMaz{Y~n=47I3=1zDx7zQzQ4e;n`FYGWNwX*?m9 zFVpPhWl+=daG5Q;Y9y=!by;>IBX+LxN zl_7$)PuctzyE}^y9}3pSe3uQT9qi7_+{sTJerAlwjGk&g7By(}9@BedR|+<|g1IWp zGQ`Y5ROXcNB0BEOYY8IG*i@uRo&Lfxd)k_uzI~b)``+a25}qQplv}G9Z^vv}y z7a>b9SD%~FZJK3JMsyw0qwAKfD4OG44;h4@*M~IXvdt#0gtU5naKu#`R{Db)J(vS^ z#Bln1HECF^TkV1Q!~xH5_UcV1C|3~`EpB$-1)iRvv~zPYT}UC#V||2VDy%iy64t-6 zWA*kJS3}Jv_IiJFKzBY7xI3zmm2H@$64jZEajmRkN2TSM*5rnxcKiAf2Th~;UJ-li zKTo$-75CO%45sGB1Y4(9U%>2^45YlkDmxj-VlS-JH_qDkE5BOk=9dGyz$7E-C55tJ0|avQ5Z9u;j{jr&|d|G*gO2UD1foMHk~{oeH}&z z;Lq#Rs_+I=QIp^2?LI5YTvaRv?{;~ODOIU7A%=eGXXItzm z`emj1c(ea-GhbJLb;VCVQ5^i|N%Hq@_@|=%laEaPsaL*{fA{MZazK2aNOs2=O~}sw zG|9u+`V)Pc6Eke~*_lF1G6|q~n?EPmFBzZzry2HJK~xO)N_N`FxC26#d}W9_3A?KC zJr}M06#HwaY|jr_oDjwl#7FpsesxIv%R}K?zLL^oTC>!vkR*)Dh~0_8KGiz=>Z08j zipB%)aYGJmXYS$Q%Z ze!mHl5&CzVX8U$Ahrw+spbVcDZ)XNyUZ<@cVLS;TQ?$L7c`Kau%Agl~&u5%wrsYbd zoz-s}bzr^Lsqmj?4?l?qu|PT{5+mv7MibaV{c;gcKbaU z2+{c^Zx26WLnpT&-#E`l{!C{7(lN0m(M{}sQmSU%0nQu?A;o>&uHwEz(Ba4j3*r8} z|A&4z|9GRpzaeFfM!*el{NMP&K)Go=*kH%GPfG!Xp_4Jh`P%iVOR$xO@`ymliAM`P zJ_uIi<7&MzEEPwMQkKu@e(!8`!HaMQ%el8lGWWtD$jUU4L1w=r8z!l2(&LZ#Nr)jM zsDK^wk(k6W$#aae4=Ey1nJ!Yu2Q2ysApE^Uwzx%=%*(AHqjk{qL^(#zjC{$@mjcru zEz?8}vBhGX zI3;GIfllM6TFvtmrLvoFW%iQBYl>DhCe50Fy zOb(|(ayH8jsB;raDJK}Bc`&`&=p+fNcwheBZt^~sA8IlfF{6_C(=ENHhIlFu3=jW+r>m{AAKh?#)g)eL*?RnhdAs~~}X%L_LGy-)_LMi29A%@4M z*YJL~NvFSDs{NTv`zs-w-1zZe;HF|RaC5Trxsw@gR&T=V?Hm|cVB9%bpwYfhtN3T4 zkA8pX%V_<~FGo}pLgB(NV+gmK^cbXOeERHSI=k{!G4Zg#Khtemav*zYymh|pdVxkH zq%CG-25U*DsiHRh)Rf)^7C_CZ zxFUaCeZLtB>EnDYDQ69#3Ctczr0DndY|wYTrpJdAwc3)^rms}B)@y@ONz@Q2S14md zDUN9))lvi;Z0|i=_~7X*-9vx<&eL$Y^b6$O>!h%X*JOr>9`ogh?S0>IB}~DYVm2E( z3YuJr@AUEP3Z_e>H$-YOEKHw@Iq_t?R3a01W?m##_eEgX7mmjvC5ex4yt6_*lYUFT z4^kO>IbU#;%w~J3P!Ps!ki{s9Q7UE8VDR59l_0`Dc5pj~J~n>k7S z#f|!hn;iBx5x?zh&YDp8`=cm&i^%8dt0M+hLcUE%fy|4e+Vuw6LQ$gCXcA5I%7kDr zF_;p!5xfu6JQn|9jM7BbCq0UblUml*KQp+8$xRF756S%>kYeMf*e>tiv;67ye=rvHE}}|*Y1{Fv~B0E?K|bWcH#T! zyv&hN@TUJO8X}v<&tTL$nLBncckURU1YA}7rrEQJ?z!q$@6LDG`;_=WoFIM4Y>%5c ztV*R77APT-__b2L{ka!8$7UmUD{}x~YfkcbY`S)D0o{GT<-)^a--ji>mrHyQ3NpF) zVi)Se8-@oD{t+w21JxvdW=`tV)*ojtd_eizunefB`S4roiSYBB=ltiz?DMx3?-0K< zr&Lhdj3*<>Z|4C11Wxy&iU&ZN=CVo(GrD;k&ilEXZH@%MKfDz?AUCt^H}Vzu z$IPWvEOs9oNH;cKyiL;!`07L15APUmmuf4w;=ggLs*hzXhZNXV=x-Qj;9Fbs?DHgK zzN)kaV0on|0=)Tr^plmJ8@eGFLmx(`fdc0y+6_kyKnFnC>&)!Q+HOZOt}w?&r+c$@ zJCO7Tm~isTrRh$|r+EqkLw#5g#SP+r3aOf?*?5w$Wrcb|7E%V>M<$I1e?}^f+)aB6 zC4A_$_1MsBE)3I!QLnEjhF^84F+u6AL3<8YUv#jG`EI%bcH|&eLN)&s-RG(1H2vPw z(~G2eKh>C~?f7Z{s&O@XZPNvNK{#zBlx0fT9S)eu7%}$W{ZoeRc$={a?!*CyR*^EB zqtErB+ngyRu{>4VKh4ZW2}jiD?}grv=e#89NCrIbOTZyvd}@4SJZ2oI(e6*C9%waF zr|(bHs`n+5r<<+h1NSAXXWm<79F8mn58$+T9d+32Sd30way%5gwNwn=ay*#HoD7C; zDV9RFo(yHumz>_qJH3bZ^Sw`!2E53Pdnh`%G_%p#mZa~f?%)o(7cx}H`Ul*c9aB44 z!^!FwZy_6$^ZzG0DDC*|FaFrE=)L>EA?7Eg;=@a8#ozpFQMdVEB7Lgi;xD+ZN~vg| zZ_uF^aNd*|nITTWrWJ`G^$+?6k`p`6Cr>%NFWn2R#n8>ShRUt`bFm-HH-q1~r!bAp zG1q@_?FGz!tp5*o=?nCDj6szVsf9c`uL9s0l6Usng+C0|mo+33zRk&z2w@fr7Fr^O zgs>RplOc`zAAYhZtfHso#F={@Aw*Os-}^_UKV$1hgp^3^S2;`VG#^iL!&w;h`>aG8 zlRT=@x&Zfo`d@cF*Vv6{7pU`7tXH8lGZiIVX@5YQ~O;9Lk#{`C!(NP9EWC-$7Y*>;F%nQXJ_2A1zBC`=Fyk(dXqUgx-tY=Y58ZS zO^NC>|8b1r+F%drI-uA!c}THsSiXDD!TRCU*g2!ez-aiBL#BRL$d6uc8f4LF?XYQi zv?KEhg=ty4H4YpJVfb)gcUY`u9`Bv&TNoPc4)ubd{j_K9UgKZe5)RTq*AF9DTtpxc zw6geojguoB_d~%flc6M)eZ#6c@FiHxtedmmNp2Hx=YfBxFUy2U{$)tZ#Q>i^jd z$Fd_6k;4});it1sj)Oa?bB$k_yJ+l$W&n56^u+@c)sUdWodaBi;PTiO&rmyjk z*JRWK=gltH=D0+jFJ!$|5%q)X->6=UFzD*MLob0Zp_;l(?){^+XYw*R%=OKGvcKUj zcr}}KR?H?xHIoI$^n#yhAfO0|r6dHXBPop~k-GY8YqTlaAQMo&3tpS26=+~%-UHTm zJRbdG9dT%)d@bQ;{W@j0nxlGY1*_GU<9Noi8DWRCzJySJ8$FT!6zWF@Qp+mM{RuiF z$*tBesWr13O!Tf?;#Dg3S(KJySDU4oy#;}HIE~F!gv~;De705DgOsI&d>$M5A;$-M z__SJ+>#_2Bqm^teJMF|3S4KJR?ZF)dj?$|4EoJM@l2jGh%IPJGR5FrvLDFO4acT!K zoZ;v^72e{5)7(Kk&^l%qcT)5S{^_H6;XlFid0G4M`>x-e#&oKruejM(<>sN*4a}$UyPJRd zfVY1i*yYya+mo$b{r1z$^MgdfNq)@qV4m(<(*$q z=JhATB=i58HSA4wf82DdEa_Pmj<2MPoWq<0a!ghwT`1zu;R{#Q!UF`M^6ykWr3n1l z(w?xTUFs(k^gsd(0>E_YkvILyKm=-PMSlt_6Xx) z$9qef+1!t1*~nkF-?_V;gdKAI{(QxgfMKonr+LT}1dpCF7wpkvwkEin@{#F}AL#$E zrYs!3`^@;YtVmThXT~0K{qaxfb(Fj>Qj1_8A&dK` zkio@I)F(MWYq?j!k7(8Wm{+BeI)xhB$7h0+I5HKf=EYP>UX&JAg{49wzr>r2pp1Y^ z8B&M6xZ3QF+i`cq@~<9js7TCke!-XiKphc_gZ~3Ih|}ಬiWlqD)=O6*6>X`?< zx$x-mSoBsE%ywxqLdSK1;Om)OUodhavLa+73#!fns){~&ryIk!yQkuL9`{c0)8P?9 z;U|kMjFiE69B;cl{kJQ|vEy3=!}s{FsePLj`wlasamM^@9|+ikLCk({f*fju;xln6 z!=VTQ$1aJdQh9=Q{(^%pUa|B&f^5bPL2u&7C-~^_PsUhEK2FCaZzn#ztt9946R z&Un?8jrY|Kpa-Cg^G^8b6{;H%BA~e4*fUrh8=kRl0#hjAL6cc@zY+W> zi3!1B0gUiV0@(f%c6!IFJI-`}_LPZiyw!=CyQ8yo-R_e}&;3H6x7l0TnZV`}iiy$n z<9y19S2;-=;l~iRU+9uUZ?n$3z3tDB!`TcAJbyo&@jy(8%0v$u1N~N;ejJ$vOGe+w z2THZN=sGhcPDJIU|CDC|f)X90i~8Z`pM*zp&&v?QQ?`MZDevKopAy`i=^wdG4e<9@ zAt~@J9asm{`u*Vo>D`5JGOYNxS`MwshyhzT+Lu+Ti{5xMqE9D%PZ^Okw(nw{lCu4R zhD5$6L0>HX)k)gsPRn#z`8uN-nP>R!uRRyQOz}I3{p#;KZRr%WpL_NH`TLnTe+%d@2YanW=kZsbltL$!dxB&AX+rZADz(KOf(Ft`@|Fj_Jl0ub`T#sA{rb zIV>i%M0FMhO`a=NX=v2zNOxF??b2%W%zo)seYs5mm|{~7C@jv-f zU4o0QV-UjUkF95VBCK?Yhq)Vofw>2OI_9oxHu9l4>l9gEH<6UXVp2;~r!;7`=0U>T zE`x-)(MQ}^C2piA-2WaG9jeJ~3R$K{?2`~RVj45E(N_d)mP}(T`&3xjvn)B+E5exB4r8X7V}^-+PKX08I6;IgB~V$I=9ONYWDmMsxwKlT)o@MYCZ2S2>bfD#+?XT3q9y@! zvl40W$aIrAq*rL8SLcVY5LkWa8SRfvHj!b;V%BG7NWb(cXhFJxoSii+-T3;Ftr-Pwr<_blL1U~nB7Udw7b7&etGV) z;T7QqC@SoAz?TNV>`KX7r(?;7reoq6PAoIKWvH;t99pJ>mGLiGT zdv9*H#_NP`UY`(Qvgeq2%Z2-g*H5#P$r7&{ojkLze9BTe%qO)VF0?>5wn%|$rlP9J ze&w*3)DqQM7^HcwRHdO&g(KaeGq!WA(K9pBtty8$K3603uendsBH8XDqNwCMW*euQ z3)jB^*(l3(=P;EX{xX%{BBCT|5o4&Hb&9Nun`X*kF{vf0QyRostKzqX=eiZ?Mvr2L zHqz}ZavSFW9wY}B4+qsOQyf?uSn@o+W!IADncwb@J$5yu1Z<3S6e1}yyQWfPcFm^9 zdL|85yr$>*kEh~E?jtr{v?O+1Z4~zF4PU8#_HrWgw{zR|uHZkN*T5)%<=TOr<$8c9 z%h|*nD;__Rl3vO!;p%fvxG&k(oQ+^BMql#KLC(Lw0NzjX*{%4P%S%_ zhFt&So_dm3g`M3~w%lVm*DK&2n*=0jL2O2u{2zNu@Rq!Rh4)0po1Hhac6h+~AZona zy`TOr9IlH64xd)|Q8%Ky-mJ?; zsbwJvaovPC1r;}jtLTo6CAneZDTNEY(v+T=g0#x5DIhO4C79`EJ<{NdCCsBFHtB?J zW*`lW?D1TX8yI*W-l3VAavNEc=_YTag^pcCs5t<+{@gMFqUuRb)Rzl%Fw2BnRMQTC zH0g5Cnn7Ac&9U{jlU; z#rKx5dUkR8J^6q7esJ&Jkq%})^WeCJQJHn}tXG>Jmcx8f3*tiEbYn9!P*H{b%3(37 zC8|>zSlHzNJPjg<2Af%sb`o|0z>o$JOoLFy%By6i*B(oY&mW+gWs0wM24(xTScVjX zk;^m_){AP?`7GP~Jrg5ky6uLav{&r>cKV2`G}z_z!h=};cne>O)^rIq#O9aa%QuVku3m8l9vP$WfBRjP*Sw>c(yM7S+yuZv;jT=!z~h!j%s z!v${YlAXO;R&!vHBqAYidJnsU5Ya4%88yb`hq# z7X6k#7sw1m!c>HKC3H*KGfpYLEnv#^P^|IWdP*}ACnE}OrMq6Z zJHcrOVK-m6j#Keq+zJ9j7oJUjV;_teBoj4iGD))*leKEoZiXSr3>|z8k3Gco!Kcd3 z$1a3Ze-LOJw0|jFko^DLyYZj8(0!3=SHHVyY`oym)BLOJo<2Bx9%4VIH4M@cN$$fx z82a}%!tyc$#&7z#aRvZHejpb9uk9lN?0ruCwvSF%jk_UDxR5hY$~cl5TCc&pm79e-T9t>H@aLv<{(tK3If_eLaXi(ILKyTlhoA?VY8Zc~`x74nEyB@^Rr zUmx;4FnW0_v%cAu)0T~D-SizroGW%aK$>f<%-N9E)3 zhX7ZC^Sq4^ii{12F3dk2EcQNhD%f1`#J~<4LGFTwr}v`XgdTNVFkY7D9(Sn&=;0?8 zn@)a*Qx6}z)%McaSx7y1E>cM(5LT}P@lO^WL_Q?tvJm6t_$cAe+G+d^EP|YalBUU< zPogAgiyEqzsJyHvp4WiC6>x#S1C_8hcxSe855QZXESjP#5jl2C&{VL!fwu=Xa0gSk zH&3)`M)m2$1>Q(=z#)OZmezan;4#+*BglE8y>YR%$_4a;e!4&wz(3ayPGU|NlGz)A zB#yg~w5ddUOR>0F(Q}>pO%;$AR}hD}YVvu@re>WC!mMB|wcsoYjxFF@i%P9CsQIeTXSEuZMY>3cKgX$k?D zGonk=%O`KUIv|Y`wUkWs$s2wMb1ggA!5Q_^8FZ@ymk;y@JG-nCo?e(FtlKc-#EVT{ zTEe!`%X*k)lVD$NS`$e!KpKS-b2kY=&+g<(G|*vNz^=3T>^5~tEo1&Jo8pEL^z{#Fj(Lg=~=K9$miCIEU4C6+S0msV4t&Xu<-x&acVhNi-I zSCSfD8I5Oy!BsYR;-I55noKt>%5el%f~3J}QYUM7^pXLS5#${7B(A;|^17|IHR*<) zxqt(}zS=K2P5Hw!U#5Q0rZmh<^rX5T96;Z{^P~NTXai*<|>LlGm<%{=yw zyi^wUVBPiS-Fh#xZgIOSAIp*`rdYd(QJHJ#S>kHjPAHGcmyg{Xc-l%fMM4LuqiDC{B!lF5Atb%YF2N~Pa8~y zDGJO?LmG|9tkN%r;{R-QwkoExnpDvc*D8QN25*4}gg@#)jJVFW&pmw6vgwT`3u-BxV}W(KV1C=wibV zWgF_A4DR72uI!X;nXKg9nhxZlAm5{f2L}_55x25UMJE8~cK4F3%h%$jqn1u$(jlVW z2SX`nHgy#cmQs3@}UZxUOP?ed&DR?N1o(d`PqB2rBV*F!wV?Mx6XLqreT*TSCT+A*#7^fEtHP9&2!t}R^!&|w&zYOcjdG0X7>Z`Z`~K%uRRduisvdX zir1_B*ZgVzl|W$F#-=bvm@2Fiei0>zK8nf29qSc@2zdsnLi&(xlnO1J`_L4$79B?q zFrGVhAI4I!XIMA3A|aL}-W_-p9*^(Am!-kdL()#^f{Z}6kS)pyOJDu9oq8>$(q6jhb#t4c+%swNPy1gM}OxJYnT z@K*g-BPk3C7lpS8pJ|r7NkkkGE}9ZuC%Pf}<wB)pT<3&}@Dp>y3HOToZc;@0$qsp(e3*RElj9}4)86CWhrKWQNPHrn-`DZo?E8i9 z1wVnG@Avqd{_Fie^*!m*<|%^XHfIpB0u1cNG3u_**0?ii*DCLh*OSjS^)^S@M_KrSF!WECa6M zGFHB+{ANYAanwI`hc6*4($u#Y=6SUtQYr$1Bz?g_Z}S zo?er>HgfIxbqVX|Qg+9)6Ud38#JIF!8>VgelH!;mO{JusNqd#P{_t={Rpw;Yw(R3w zrMW|QhvXk9F1Wp05Ai zF!9pCR|Z!?8ZAvS&AjHN*E3pbTDn^DTK?P+@F+eEKki2Sjg9zn{QFkw)_tuVt+Q>= z^3J!mwpX-&?%;Mzx$FG;*z3O6o%dXCv~QN)JND+^{lMGMw=?dae%t--{JY+FjqhFF zM?YNjFynpQ`@#3Ek6a&uK1_bJ^wG8tFFT=5QRk!1sgKN$s*fojpL|mMH2LX{PxYU^ ze5&jkc($%<(6g9l_n%Eqt?kx!k9eN?{K@n8&!?v9FE;k*d!D?kd=>RN`t_>Ud*AZ? zC|&s6hwP94KL6;;`W%X!*qr*D`OJZ@bYt}IzT;g#m;btSQZjjl$Rcj`jr{xa{}w!4 z@C7xoj~DJL9K_F^n^R;d`b}ItpI*{jvPwNz+C#hLhbqV_u3I@(vCW^cRxDn;o>=p+ z7FPRO?N|BH`i2J1twjwx8t@H&v@^G(8qYLw?%Zse^hV>ZcSe3*Fqgl32%<0=R#KkAi6)tbQ{N1lRFJ-PMo^AOZSHJ!J(PhK3&ztZ}|K_!;uYK~zkAM94 z$Fo;WS3mqQUki8EyI`Y9Oh%aI#dToduM+FPZ!dt~G*tkA1%H)WhPHcxDge$dBoBFH zRgvAmk_k!R1W8DmH)23MY?@l!3l=^YWt=lwsb7o-64QcVCh)^&0VD!&v>E|IyBs5E z8mUN#SWAlMffh)Dh$vP*T0pl6Q zr+(RXBwE(zXm9R^oe-_I-dm%!RLm$%5fPlNaUP1^`8J*)9X0!KVsJhw7J^Up7tl3Z zyJBto&v2{OACJ9rM_|m-0E7TwsU=}OLpqHIOl#u>Mt(O0p%^Nl2JFpG*u0kBJjnb# z_d8AdGu_T}e+uJzP{J?_>BkDe14$bj(;)x3`;9J*Dq+`qf*2*}`@G%+EZj z)KII2ov$gHuK)cxk&~~3*m>k9AJ3RV^#E>&i_=cJF8gh-iZ1lRpbvhh#4ot!@CX}mW3O-eq**RCm6YEOb5HEw_$Uv)GP?V2nvHDOo zPbG8OG35L8O-FCQqZRnf`&N6gPWb=u38J}lPemDEmg02f@ z{L0!ncWO^}dsKEt5}b<%_6xOd&~U_3HI5d2X?k|P#dDo=1W-5so|lAEy!M$%zy!QpP{)B2LxoxA~f>p zdvqS-&-FDYl+6gMli4Z7E{mU7lkCx&W#Bf?v-8Z{A}bPZJHVQlEiB}4`3ztaXOS)z z1VTVIVw!42Gf%dpx)Iw&MX!Rw5u-CN%_a+(NuS+whCLg@JFP7U4B{^`udaiv>8LIA zkz>D@MCns}?!3S+{Pu<;QLFA*RBo94NvAcAdZVwmQMPHn>b;@^j=jKHlEn9`*uYX@ zBWd#s2k+jzci`8IxYP(>@KU&&3~QyH5W0*{d_pcQV$z<;|yHqljkAoPBK7rmJr=F>f6gxd_W|PsDRR znc47n0)z>j7bp|K)a+yj&wk-7McQ#2hGQ>&>Nw?n@uR~#j1jGMN*1iJQQ!1G=L2fW z(p9v+efx5qHJ3L}UXT?ogWWQH9aI!iupQq-s{m3=P+I395sl)0outNq1t>sO$Q0x} z!fMet!<_crslp-*O8+nTBiJ7t4vR`>K1#?$+$~fVycI` zDxx3eg*B$(>UpX3uFdlFk)!Nb#Ciw;_PY53VdmftvUNO>0K6^T+@z?0Pe5%RC*@!* z>ETltDvYrxa8MEEPUzE4t6vKM0XX7J;p!*h9$u~hUNmf7Quc$BeK+mO*LDrO1LMf~ zFb1T_3?<=S?M75FyPA+K^ebOyLclS zJ9!v-u)#=fC)s|0ouqf^VV=kN8_Kayg*ClF&oHX-17$Y@cwjuLVgi$Pg$oh%W110P z2R(H)=Dp1%bI=%`4u$u*?0w`1fQKlIojqtZ3!Q#i!tFAbcw2Pn+Mu~XqpR`%m4C%% zO&dQur|ny(KLq5wMF=!$`AY1yj#+u@wMdJaFxb7rJaSsNdLpbjl%BnK^|*8kmY<_e z2aX0H-5ilDTZ5G)$si1d>>KkflP35njEQi!c8-(`o!W67tsp@Fx(Ik867O-q*m>Ur zRmOhchBp`mzkfX#+NV@sajbi*iJ@@rO@xn}Pv#B`M57zT;r$f6m3-Yp_%Vzp52(c0 z1F3R#U&XJ%1dZ(Pg%_y|r<5#o1)Sgm1K2Bs2rzO%#6+-dDX#b9xg7H0(HCU-i@~2; z1-|{L53tvO(tl-zAPV2U#1lW#yA7%+%jj;l$%-*AO>68LsQ z1=25o$J-%weTYlA^c8AO?*&DmndTK7cz%rvW?xxQ+f)lH>mH}qi}*SrtqxsnLH4BR zrCV{Iqq*e>W7;hQ2UPFe2IRE`RAeCtsgpd7AX7ONcn?(X_zK8IQ36kg-BCqQ9DFc_ z&x2=Y5K=MTBHC~rlX`&d*Jz`yS+;0I8t7a+aw(ZE(ND0jk%|k>o@yK#bq4J)yUfr zmvaYKM+`;zRqsN8^u01_m>{#56#e=9frwo*=}ILr)4}RixQx+MiU@FFW|*~}IJ9bA zCM5}3mPRoHu6|`H#C)XxudWNi83teBcG(n;D2Py-jY)PQ8mk<<0XADd1#qj3(a@T&ABP60|}HsXrbGMajvrEHMzsvRe(5uhcm0M z$ho%~$*BM$f)Woy=N_0vyRsi{;OjL4>@VjOBrbrxDoOLOFNfK%4P>u@z0OOxhpCQq z`p6D(@$EhuJHtFlCyo%zVa(VHSiTz*;X8S-KU#qmnSs6~eSP}+9IFE91*%#BjA^CE z;g@nBX@2xzBp4451z)t-Y~b~mQHA)XcDM0uGxyeK@A`B9cpLrhwZ0cz`P)FVATz%W z`PMfszxJiA7e4W>XT$63uHTS+GKk(iSp=&Y%N0msJO3S|uAK}Xtp%VId4hW2OJ5b&lW`g<2jGX`r+0>+f1-uc zEau4q@TFCl2Cdhj5)Q*pYhH%n6|k8CWppx+l+Xb=;EYWawuk^3D;YXanu(1Rh-ul3 zj#(&xHq1h0Z^8ZaDh>VnMI{`tKt@HUP-|bW?s5z#C+a|YrJ&Q&Q%c8#1vWD|1n{B4 zr{&wy#I1D$>>Q>g(do# z(H2E>8vqyPvqx;hF*HUA_p>pIKAFGQ5FMR}iugr{+=elXZobuhFH}oT?*2p#I)tBI$aV8 zFJ%z+r+|mT@4)yIz?GMb1Lbp$7A43U-6<=Jf8s<(2S$v4V&T+7#7B%G^AG32{8qe+ zVJry8R%D(rI{@Mg!4f`C?^4fKdu)M$%?I+suxcxyw8bx58c?OX9zMJ|3hf$!1^A(n z|7`&L=H*@$KXqvAsO>FxKO}fm?sMt6UiQcEw%Z5ES9gq44^j$hx`P@_Kefa-T0T>a}mvW`el@5N}ir`8aNo)KG;qA~s@j^q{ zeBz(A_}o9<4EUb6CYHy{5QrrBEwT+?1(pi_;n3`K6tJT>IoKf9z|sY*^VG-t*j|K`cgA z0FTV|90y+Nc?|4B103qo0OG*7Xtd^F>PqMmU`cgHCqiz7u=krmLc0kNEhZwTAWu-Q z5MYvQ?bRK)NLk7KD)e+}!_*rKEi*F>s<3{wjOW&Zf!@-`O><3<2myXe5A5@SlGLO9 z0P87i9q0v#1lE>K0Ho%%B=J-3DjA zn@8|-Ez^E7n@y!M8L$2i<$DA{Q6vZ``76Z#?;fm|vmGpM%STiHB(QYxzV?1fSX?MS-_YKBmg9Jyp>4y`tun>{DUsxYuiC%(LQKi=4t7_?>+#g4 zHWg1-FLVS!lITgjRLYoBh>)cBJo95QG9^iPl=MmxKOzLnxzPaxrdj(E0QwS3fUJ|v zgZG0?EaNy(k#{zhLcOK#KC&kQ!g)^MHenU=o*Nh_ z1!U~(I@;25|J^&kdb+Z52ADoPVP5-762Dh%`p169KY>A}^XNad*KdCBM)n1gxa&M% zK>{0NVCj~h^Lyx>>qh;)3K10Z9$TFEj$8}SFlCRy9frZ%E!oQB0(WUnwjM%%+hTJ_0bkSHQP zBJyjXW5mZw_mG5yGuQVSEN$3NV*n{rkFG>mg)x6&wNx@nu~FYyV9Xa$Mx}!T8%1%u zbJzOC+vd}$Z6A4+7sX}FV74S;SKNSs&hxRed}7ZR9lN4}hZEGjxAn8g*P-8%?DeI> zyu53deZ#jaExl*Ae}A>R5OVEC;*0y+6zy-D_=9TG<9vG)&TunS#q*mJ->^I(e1a}; zI$duSFG;75I&w<97@|^%WzM&T5qY%I=p ztHSZrs-^B}LJ^IFYjT=A zuPKTPF8W!`4*hW%+S+yc{>0}x+VgK%bW6$N`yQq55eXQgYxgz-49sM_4L<`jm^~tC zk=tC3ujEFq=tBe53kN-YfR(B|%`#XY7QCOh8k zbV9`81>{=%2vw2B*#Uo%Ib&>!HaN&I%XCw4I|*k#c6u@Y+t+SBCK2j>NiQ5Ig!Ikw zz4AD(7TL*n(~8zTIR~Tjr9yd+y1%{ zpQqzwkR*HEA2;=NcjOJSp2RRxV$`nrY0vf(5DDPWgeQ)LnEwG+UjV$iISqpt-0)QU z)FsPN9C=3Hy}AJkn_{e5x>mQITbJchUI*zPeFy=u+Rs*9CB-35X7mD5@>*B7e@WlV^bN(C{da<1iEXOCi}e25_u(N|4YodmgB^MX~H zi1%k}~?E%d6Ko2#bbg%v6ojb=C3_SX1$NjiH6Nd<)AzcXswa(1k1IUZqYz zuB*e|+GDe1`i7YIppi@ZD{Sn=&)!)5xIk|L1gSo#m?oc8N^+%BD9Y z8eU7Z4WZ$!dgf;>cXxZ2((ZY?XiA`&G zn#KaqFo4$a>|lb!-A%fR^#pfw=W<0(Dn#vud8!{0=29X-8cXzPz&zk6RE3#B>hq^U zh$B%>UkaW-=d!h}`69)dwbalI~fRfqUH13i=+ssc0H9=&4s6_9s_GT z)cu;a`PjfJUjRp70IEVLFwiL3evE>pcc#5u<}W;7p7=453X43?i_zo!6$V_usDTxX zOS_y(+D1syz4WeOA%F^aMOa!W$W{ErzCPe0>eCW#2_QN3*g?p`ctZ~*>4N`xa0eh!T zC*jWTW6-EA>4SA?8bUs`)Y#_IzB#{dBV_gPwbZWv#f|wFa+DY`~ zQewBZYF-3gt~+?$|-3vp6sO zoVH=$mBKcyah%K}wxw-eRIyC?Kd;U53$Kf5nWX4amQS?Z$?=Hi!S>S|)|bRoeXw?Z zf-fh)?qW1J^Zkh9d*z+tj_AK!#l2E<~95c8pFVhdcDnyq3#T3}vO z!)x^6p)(l0C8-8%15fg)u4}WkHoKRUbnDK*VRbG-c;D35u7WqBi5Qow-RN=6n?uNn zVK-g~S&(>nFhZ;|>P1~B9ykt_UaO1HUW)!os}=Ib49Ui&+l2BwB*P@weE?#?3c)n- z?0o@xKzKKkKIk)n*x1)<6@a{vwN1M%3CRYO^};*u8Cov_ebU`28yS%^v`!t5m}O0htuZH--O--! zHS6P$b*xL%7x&hi^qd#RufCO6aO9WPc`dGWyHZvT)fHy6IR18~@MkT~DXae&Ie1^Qgtj@=MC1!Q?r&wou)j8*xs2{8Owi!?}vM z|0e2UoDjs)oNBZYwd%EV=bmb|m|`7*a&L*iigKec=71taSl~i}*aLDlh&XQ!U37Uj z@^_byWr=vweD~^2MF{p53vat+Z@}X1f-!wYrg+}B=q}m$yX4jZ{R&$t=$@Bqo*Wn zx&NZUPQTq9j^-&W8txp!n#=&dzdnmbBzy59Bxlw~ z@7;Ce)bB8P{_toAkB3*Kc!}~frJtMn=4ky4ZmSd3`D3-vdoRD#w0mi8t|eeA>yM@^ zY2g=;uAxH&4G&8+5+_HS9A z#OU2niXQEoP~-QJG`Hpb$CKBfw(MuW-A^ z3fMz=n1VQT0Rukp+o1GV5p2SaJ9Hp+ExO3ypR8LgWkfL=Z=zg6tiydKN6CyHAU$;9 zB3qo-K|&;luE4P>Mm$Tg2h|X>|G&6j?yZ<}7-S1t>i_;ha;YO$fn)}AgVzlGw8>pGxk@H)sY*2X33$8= zHjHu}rM}69Y!Da?O3^ijryv@-fcGx&Uygx_hk7C+GpvZrfWXmZG&4xjG`37X%2iU@i^1)cP!FT+?c_UQ zt(nGA*IU?Zz$~nSb^P9NI$yrCODppeUawtO_}!Uyg0v2jFlGy=&p-fMK%~D;uN!}p z8y4Kl*$lJiWI<1a@>dFr(wC!i&{sqbR`%VkQ`zY|%*bTY}8DwC#OT)nz%< z)|>yKuYs$oAxFGx-tmnvW=TPvFCNnWXLB@O(T}i#!Z9zTPJGNiJ1zg=m|N`W;#-|Y zSV-Ai$fFqkK8GYd{8x8jJHs835{oGh_5P$(5SfHnu&84>|#NVsV%0S&+ z-~2(aJyoZ@K;eBn73QA4xBty3jMqVuK88hT zL{fOmzsVLy#KVjTevss|{7>pf2P%SI&hMu}6bv*T+&Cv9D!N69VGbpGmt+4YN_H=6 z{kIrdv1xNR2gg?760p9>~K>#LIMwZR$bhku@pBIqTGGFOl(rasKWKdSGJyKOH2tpG45CcE>XBD=R zcT7Ehr>>4D(dZN2zI>8@zJ=G>OTjS(gkvd~qa>jy@{N{sAxHd;O94h*ZvKB~Vr6B+ z*EgrZ?eB=ynXec2CgK^ZwC(T$>c@tYTmLX_PQLV^czB=3s(Ztx@k0=(jVuUJ{LLt@ z^>`3?zt76*(fqyp;L9ZeD(`)>@y!i#_i_#B?_k#cUO8zp(?R7s#zhzWt%D<*!tLeV zONO)hcdN>ki*_Lj;hmGD`7wxd^AD0VX%^547I1?FEYfDywfqBF{J15{Cg|abQnD~e z6IVf!lJ#fQt>dhk!DnM%Kb_egYYjLVI=`W`_FdycIC%|C8^bBDTb1+OvmUr==q-&5J2~?%L`6VE5u!4g7>I9Z$2j zVH4fxhjBdzodLp8Uj*4;1%F}$GA|j|)Uvhog1qV^uaXcCqAaL=U zK*aD=1Pss9VKDuxxy`mEH{YCUK=E3Y;fM3byiZ(OdM8qP1sa8E%tY5_Ov6N8w3N7k z)Q?lq)GSNs+a{Y}J3H7>HOYHm7n#)W3v&z4_wN59tTB9>6gIN)-MH}UTiK7@-+1p6 z$Ncgu??DA34nKm-!~I6bA5LUfksqtW6T|EEb3iCVelBt|DPeqY0CaG*3RvU$5KOl* z_>1whiov$w^ed!?AJF2s~a@tDb-Fb!J|N+Cnz_?^0+&qO_ObhP3^;CN2i|rkoiiL&ESpyg%7J;)2BY^Pg#V>}V+=!&Ib4Uq%jBR{HsnJU~E} z5z2mW^;OI+FC<4UW)3NfKeznrQ83jOmY?Y(RnTr{B9dHR3p=xFj6>!^;o@bKk8@Hq zok(YiEU4d0waq4KVEI|ET|qm>#80XNSTpXS&;?rTz(A)Dq#kYZZW7V`(x|cV7zwXGJ0cpCx;sqcPh>b=qDftVB-j}spYEBv%~`fe_uS+}v%Et||R$C@0!9F~0m z27Cy7UDC5n;Qf#b8q9ky#4eYFoe(|myC7gV#*;7~91z>pFzm3^1YUw%n1FfDLhQYG z0CtvuA{8{McCnmK`%;hkkE48gIu;j*MCIlen`(P(Mnv_i@n7N$t=@qzW0R0kw z!4Caq-2~nSxsZbSnavuN-fhZ@(*nk50ha)B^5`jitZI1-KoZ?dz6xZpMyO?f9R7kAJq%A z{r>~)LEGeA^BW%>YN1t3_K7UpyK=hiGK0?r&qDYqw z(_du+r0daK9Mjgakd*y&vCc3WN*%m{-wBH}})Bj&Bp zG#iSkC}l)e@oO-R$59cEkol!I-ZM-m_e z(*E*k((4gW{mY^ly20$tPui|mSY~oLaVw(*PBOx*prXDv>LsaGqY>h*nner1&3&(; z1*nGSB3ZiLx*Np3v6w-gNDXYY`PnB%5LK3;945G?L8NQ|C}tv#MXUowKn4ApSGYZB zS}m=RlCO_iB3m#T?!h3!jPC*2oi=b@Qh}8j1@oW}!%X-LNKb>aPD{9lsg5`bLI8j6 zqglk5NP!R#?!gd&neZFPybqkCA9tPH8@YcX{aS8>1Cg%JI~lx?>hj!ugl(<Yj;=^Bgm~Aq)#R^cm^6-M2m@3=@MeNj-p9p#BXsFxK!O@;_WD!?uTTjosYC!$SehGJ} z0jB>Ccrm;K9)Blz;gSq^oxl+;LC(>~@i$+#0rJ#%?`emUUo11FV@6!=6HoFWCU8 zQN*B$u4gq}KwDn&;z6KgL(rA8MTAtGX_t=Nc>Mtz{m5N>NB>+m=P2kXPZ6;XEe53I z2*L~qP{UBu6{>s4I)ZirdP;JDe&02vca1;clCytR{@A+eMv$jWJH9e^P@q6T@c3EM zxNO@^y4*Ida+=)xY{uw)!n}lD&Ks>V^HF9zK%5yWNAg_|CkRrjh~a)xF;22%mqt5F8e~^7BBt>l?_LUI^Z3@#IVp3Pp>r~38agL# z)6h9lO=v3OkJsmG8qwV)sW2!$909PH+Xl8_kR8J_vjyi`N~XZQ2ew5Z`zs!Zit@fL zrD$#GFV1$dIYgMNG}xL3*;(VEa$s+*e-oipQ0n@cty!bA|C7o@hy2AD3J3VQDszWYde^R;T8L8p@$a{8=k-_jjXYE5$PSJzwVCe3Ex+ zU?aPXxbaadZ{y7M10q*jJmA`*BMi^_lGA#KeAm&^j=YyGitr(s;b|A_4_-=fDJciI zpZ-uvOP|tC$mDNTos2)!m7ptlG3D*pxvBrD5OSHHp&Ag%$%#+jUpH>UV)tWT;~of! zxOW#C%=CD1LrXEmDiSfB_!XukVzExsbW0})^fve7(lu^?4(+C`f=pXn@!722qq1e1aZHHKcK!R45{|W&b>V zD1tZe4mzL}O5h>C5~V^h3mc%|a|nFYu9SdL1xkpKUuR~uvXT>cQsa&B!g|J>Kf}I1 z(r6PxNjzC+1@qi2kva@}6=KDjmzQ^ZT_U33zxl3H+sl|V_j0MRN+wE%W8ocSz?9C7 zy*Rydk49};Tpci4-afJUE@Qdo`@xo9G*R|&w)@>CcA4iWL_Bcb1wNnOanEo}P z&J5Az2PT_@BC55oauMRqtIROD|NnYvUF5oro%;rfy0Ji)ms}eigfXZ9{27p}Ar2Zsc9} zjJM`hhLi%%b>Q_*VTt^eF69(6Fjsfl^?Sgw-8xFPeK=GsY0aq)F+XFeH%UzbYJRWauB zwpK!{d3gC(oZ|?jhY zel$VSd>@*=e?7RUl7K_-7C~Ywj>F`J_(-G-X8waexBqn9qv?B2H!4PtuB>bXc>uyp zNGT8zMGO=)W-lAMZaQx7p@chuO=_aY1-y8EJ^;Yu{}hVAEC7Ng3A_>r-tmDMcujLJ zaoY|;x(PK9!YN6Fso}>n2DT0i0yi=0Jr_^<7MGC)kr9Y};A)Xt?kC-fAE z4w^Nh5<31=v^8}&37?RZ%~>wq#j4r=X_N{Tnm2S%)=U&ycc868-7E|tqQdj3vCtj0 z^sdh}4X*-`K3eXQ3OF3i54)&rvuHDAd`yC>Tg7dn0aBQgVAaS zpfR?Qz&1|8iji6Y$ceHfE$D$2w|gVGgSOf%Ucv>w3p|;DOj0cT$xswq3-v|XAY2bJ z&r8gy2=qEz4uJFJunx-igAGr$Su>3uh3`Py@q#T;(_{(ws9Q|%{%enz!5hz4l1;HcL`E%!+#k2 z{Z|#sgbCl;S1)a>X+DjB!t(N6#%|lxgwLOieLQM?FiZGzQDDUL_zt%*699>y0s4bt4#YOni z;>uPcTUBct^(+vkVuGD70jWvGseN3G29%rrlp+iy&L8Ngem2m&H}}QXwAdPd`yQ|X zl6f?*toq+fNzAdCcpeb?(iof{yEURH&<h;5Pc$ftbAXzhPJL%M@~B4g z<*?i_f|19Va|L8VIL&gf(0fgaG{JjVB5xN42@tnp)zW-EJzuooMm=qe={TEh+C(t_ z8o0~gTNY5k#KIEhqlH;qi^df9(nSIj^7pQ0pwcFqwrZ`Tnv>Z9kBRSIRZ&x0_V_iS zs7H05K9AEYR=zUW()z!{(0@UoLa$E;k;F8=R>%}84Gmh6)@sN-2KeQ+^IbD&`Rd=6 zuUS5n{ntciV{l)G9~LheZu==>q7+^!suVJ2hhiQTarK2-*M9vA-nZZ^LJt5HW<(Q8 zEgOo!r@{+;`}_7orf<1#xo@d&zXNhcRlcbg zU$jT4Gjedp2P``p6J-WWD=22fY>GMbVIrLZX2Vf?m9N4doyqq4#L&*)*#CtXl^e8J>4wKrKAXJ0g<$G&lJ`^~Ky1N-?8t|~t;(EIQ- zSk6NcWe1uemO6V(RL}h|7v+SgmFO!wowpCD`pcz@0!!QY%poP(@RBc1-vkg?eAe&r zp*;14Pdhzw6`p(zEv7OgZZd0CoJdT)A!?Fc2j;X8RuwO}?xp?7bUJ@RZMSun8FgdN zJhmf1A0@*L3-pr#0GTq7E8$O?#KeXC)+MD8V7g9ho7D>nyc8Imco`|Zj=5_h84~|ijqqpZ3xgCdVv7uK#imwx_ zVHYIBG!HW&GhtE)V?I%fgWqC?6AG`8O0&AfQ}j0sCku8S52D;88qAwu1C)0`2v2Qi zJv{9n6Gd?Agwk)I_T!n3 zD?B&IB!XQe++!qcHMHH0XkE-$9?`l1HsN6PICdgp%zSJB?;8qCV5Eqy*{ER3gnSU9 zO@6yckl22lNmm<*aq$MN*5Aacytp)RBYOt%R$Gm!z2cH8aqDapyt~Bh1N&1|EHt`8 zx8r!M46x;pI4iNO6iSBnP!os6T^eyGUWmK8;2zh0wjfeQ$KN!~PZLEE){A58Qf4a+ zCB;IE!cNMfR}VN$Z8~Cgsq%N<+|YWM?U@TRl8h*wZs&7ZpZRqYwqYKM@(61Twr5;S8C!|Q z5A4y%1YD-Y6IF`yh0=&cDVi^o~8I$7FY$Bs@2rAoEtu2pH(HkS)g^0Pln z{Hsf`zk6`=j>BwE7in@4DWxifTqY4;AdyOC`^qy*tLB$o=0#j^xFOy&i$(y)*xs1+ zF~3kGxkTX<&2YU8MUl57R)J^ehBVPknFXkreo^F zAl3~Gq^0RrZ9YpaG3qgVf2$}xA%%1iAq{{>5l0O#VCAEtjOA?n13fCkOSf<$v zfz{8JEAx?lMLXZSWk_DMc8i0y_znyLSPBDAmq9~~Tuh{Y;ZL(DmUvfN+ZfPDZCw_* zoyo`B7b2XdklQA(z9wG~J38a_26Ul$3?BkIun$Xfs{dEqCXjGHDRoezP6B)Dg?nP_ zt}`AuIb%{3_7gDhGv*D?^3Zd-`ih;7sk71JR!!mn<}95HF}usj77oeFE2m^1!)$e1 zpr>iZEXxlDMbS5Bvw;;M3+1d!i3Ou#;q0YnGj{EE?huo$wxBa+6jfLrk^m@Zp}qk# zueJfu0)v>_fpt>X?Iw=d?II@~IIK|xuBkJ+onQq%j&1^vWW|{1f&0}tEexP&XKr}F$!C28P+{kA}hp{yZ$ zaKw&nV=H*yA9ee3Q+kb^C{aMqA9PzVQ0W*~DS{$w;Ncf`HdM&&H7Zv61*@3NXUj=# zI=}gPnaLHRzeKq%3jxc7iK_CX5;ZXvGai z8q);uBkqd)J1v|O6AW5lz(g~mxNB(}Yvp$A2_xVN5CXLX;12vYZdWLWjqp8`eg+i+ z#c-Q!iv{t6x=OpnN+?}~Y5CFYj8=pL;QFjN2owy`14Eb~L1b(66iMQ_ce0`_a88%; zdZy>Jk`Xe7czD1o#+VO!LJ9Wf@uU1n(pEq*H!jHjMmNoN?9sZ2ovH#I>&7cM2IaHH zXYa+SC_ipNf+gJA(Das72-uGvn95~xB7dQlZ}{YT{td{VE;kPn1LzK~{;%y!&)=*U z%=3)lyW@={i6U@V~0R_1oFT<10v}>3qFS(wP@3=K@n>bmv#IK%?=S` z{$y37MIxVv-;&4|0EWJng}DF^Nw|Nf0o*YaRt{8L?xcG49;FzG=q+&$TJpRiwFJ#E zzwMPDWVu>cg{M_Uc#smn?b=bn5;zW40jc6)_8`@NSDfpVpd3MwAmkLZ06gkM=P0E~ z9hV`O*<3p;P)YkrC&~Z+(jfRx7bUOkslaCe_88XH$&I)P%c&ls6S|-aGBMC7Vv6m; zV@X(;_xB z7;FRYcoFAmGkzrtB97uTu|5LHkOTVTQDMujQnAt7ewCYq$A2m!QPVouiT|#=3JV;I z^Cueeb+g?41cPu)bRMFJnxFS#SD~QmwVlMYAp!s2VTUk!l<3f-{>ky<7{(oK*pr8! zQxs#Ov?Ui6sspxg7WNo4UlMi&uhA}@0inW&$?&WFhD-QQ6 zZ#$GVzkN&qjk`jKn5Pe0_mh9nL_>JV$wCmyVFq|#0b<~RPO|0R@vYjmTYSuQ;ezFD zBl8$e@^Rqn`VaT9oDRI~WUa#qIx0mM%)2JuV-cpt#9i<(1`kh?o>wn!8pXjpC95u+ zaug2M808fx63@Psuryqu)(Qn=(?~|Jn!dhQ`?I61%(XI{9SQT7JrPTYw@<%mcOI@d zMtX>v^MI?guvV}rkCW9C1Jj3HnQD-1l{QyWQ6+r5Zw$NjrDgb{-W`@+8w^LS+_rOK;RP zQ#U;6VDQ?KxB{W~s1RZPZnK>XcZd-ZNAJ(B{i;;%OEpKQ$Y~c%ST-wqMXdV)+tS3DQu>DI$@-X!-g9ed`B1b0Lz$LO zSLc{2X>7CgmUS|RvqIsf0I&jDfomtJTl;K%ON%Q*Yu{CUtXdw%rH5ewlPdN1_GL1S z#|NT^x-Aov4WMW!>dwls6=2Gg#zep49G6=Dr=2tm`?kAx&(3ZLESFFq^;xDjL4Wlw zZGQNakGF~0IwkCKtzS+88-kFyWsC^U39=Xl*_Ib2jAvSgNHHVBa+6+1sbfM0g47^_ z+dzPsCDj~;tI>##0dSLQ=}a!#TwiF0v!M&9BcBN0g)+dKuCJ}JMZXg315zI7($X)H zx(Jlk4_gyBX!wODaL{8Xa1gW=%oJ+`8)98wwyfFW<%nJM<&Qw!rQe{Y0}q=%-vDQQ z4^VaRqc>qC)h%(m*6MF!-zGl$6~z~&--Z4mG(}zD z{1?N6?i8oIdzBr zW3VmdC(HV3eXhP(zpdxW6PXBIEYz%KY~(UEGx(yGR@FUa#45a1nP}nm9sf8#VoSGI zR)gr5L5Suex7{dqtl}4iSIkj%wN92s9+dm{Q3XA4?=iiPYU9uIB>tF_a|w8!7kNpa z*ADVzd#XmKAs=QSzxTkvlxa)8saA9o{YBc%%?%QiDA!`hh(Ucm!*}*Gx#fGfp&v3= zrn+VtK6BtfP@uZX3qzUu@Vg;^gbKJ*ZS!RU+f)W6dh>37f zS@@tArB<}1cUy}ZBAf}e0QAscmlR_oQAvRrfXZUP6;XeWkMf!G02#z8fA>n;aQq;Q z69x-=$co!EuPiilp6?Gkwj9l-x0>sHji;u{x?y_M)z-?M4spwcydcub5qe>UWsLLJ zzV4Wt?D}$Q?g5Fg4a~`so7`G@J@iH({1gKkP=(1%PQAtI7Tm%L;q53Z zGNYj{ogdb$MZt60oX>$cpHwBpxQi~<>=>DPA&t?hN@@CEEAZoJ+q|}PZ__ClP&OUk zzy3zpWB31CyYbo(D&zaA@D@SV(7!b=qrP@o4RiyHE#NE>bT&Mrw++W}%3h{$+0<`3 z?SbJCD-hhYkr-d15QrGMZ1+ueRUH{-n(3Nmx7{Re)^+S}bPv_hO7zZ_@?$LmN)6qA zdJgv}Ip>TG84ynCumTmRWCj9ENAj%Om0!KxiYz(PR14?XR>tBvf+U5HsWNkddSIqB z=Z_FB7k^Q`+s)h2P~Sj%EK%Mas}OAOE^W9eGhvrL#u@FmpGYPQqlm5gin~j&ANAe! zwMeMnF{(B*mI8KfgwqwQ54cD|_bj*NtQbB8?xW)JGz|Fe;eU|-4mQ$vJ+S`sk|n!aV&{7$yj%hRay zy>|C$2L=5^J8yEHe|IhNLSj7pm!(b4uy#A?dVwECX%To{GR}*FRd?G>l^x}Z=86GC z^d(ZjXR zs<^l5NqeIfS#4EybjHC|s;<(H=0l{VR=x7eHLEg; z#EzAfdORhiH3KL~3h*i$F`t{1V*rx{>iw`8T1gRL{(>ao+gsxXvxOidqMgRMg_sM< zKJ(gZLJj}w@!V`YNZRd$*Oi#-cAp1co)8)i2Z)&d?TL1Fl^1OeEmYlNV$`R-KGz8} zRSQ5tTK>_YBky&J@o+Sq`u)kci_YfixdwpOg1EXEr(EJp@dIN;LCg2TtA-cQFa(5o z;JpmZOBI1wpcInusy1(j_R6%w=>@muRsKzA@J+z`3^c<}AaaTWD>Led#oE{GrbV*2 zJ7GceLfmv{V^OV#NAd_F&j@B+_cL=LEyw@M42&$hB~9cq}g_12J8V<4fUi?KtTz zXY7WsCZ@dEUAZI14H%$-ZY*dTXR5iaFj^yW_AW8edCe+(*h;DeC3Zr|lS@q=qyPTW zz`eXrOfOTPXGp%W+|0H^)USpd-)2Wl)rY$MPMsTB`s7X4iLS5b;hWUbyV+ExD%t29 ziD4^U{cJ4kV(5H1n=kc}NVsq*^)V|eRNJ2)6$4A8`o*qkL)`Bj@wnYSrCy&`Tx#6i z+A1p*;OZjAN|IQv0aBbeF#tlM+Hj+|IWkoVmj@WYDYkX4nCGD_he2N7m3TfQAXzdT zLXDgtQ&NmrE$ZfdNW-JnrfER95&>cm`%)|6v6e;QnAe#kh=f#HBtFRyp`9TthDI2N zunz(koRChlCH_V$be{+=n+m*T44=Lp8dWU$0C0027H_;0vJb>AV^XCBUc+VmGCs_` zJpRGU|0B8BaL&|8PjjU-y>bZkQbjxC;jHSKzz~20nuVAPeV9}9*a}~_zo)}a=hSMQ zp!GuFps%7A*xZLrKqdmY3$KJ+js(fLilv^{z3C49t^=N{w$?O`#2zcHARrxFvfOAa z-t|oIva?fabz%9SmqHD@Ru6}#pl5-rpN<*E(jkXKS1RcM1rwdyXx?pSW~C!9xk1n@ zG%U%qaeakxyX>G~Q}AbhEGJOAJ7eg?&3{J9urHoH2YUwY{NrVbW*cMd|Ld(JyXN zFEe|Kkrs5IvFm1CHuf=hD=%0Nv8-q%Xb4!&7o5z3Lj*FZqXcEfhG3JZQvjmOtrLu4 z7s#v%jN_(53zOmU3Up~Snk=(RgHFfPEH=!eD9wU(QJ!BOx^cs8z)6r57aC&IF(vMP zFii3AOjkKx2(RjPQSd-5NdOM>~$fp*yy<;soZBIu!iC9UWHHGiJ2AP?~8y6dF2Z zKn_MI&cHZq@WnHTyiN)z0S%Vjar-`$q0ax;pte6%+bx(*; zzY|gPSsxMTe4Vt@M2eQ#Fz!loDyCMGDazF3p9hF34^!D=?_!X>q$#IX8|3_e)*U?8 z2NW=fz(Gm~6mWZvA4GeGR&#+HO_XMB9W(@0o`G$Q=Ag@+RGjxaEf#<@gS-xh@Yq(m zjeq5wte&ILQ+BizXvBGq6vEoKAy$rV&_`-gS{}9T1G6T!AWJ4>&kOHl)(tn>?^{ls z9pYC`?6oz!nP*JQdm!dhQ1@AYybH2vOJ*;LBB}tzeW8Sx!$sS;f_f-mvN8c0MP&0& zssp>)$%4daRbKU){Fsd_JG+lhhd*U}-^2JQv>ZMiSPbJLbNBp7XY~~GvDru$X_IQ& ziA$5Tnoe*=fO7cNgo0=GA%6o!dHp{7q>|VShuEVXRNPu%x~LaejOrxrrXVj+goKtH zchhUJc5(dh3j{)=QUjd3$(8d3PRI?pNV+*$2thf-VYLr}xGM=7_yrkM4Q)ZOnK}fB z&@*)1g-87SuM4U5FA)wGoV3BVO!VbnK#kIFvJ*gYwxIm^VZS`Tl4FE_*tzlko7$CG%63=1$xqJzfG( zZ}!gmkeu+Qk$EqaeG*n5MR*~GZ-`%1rw)tjf+}9bpSDiW1sACxy$FjOLnt|~aUyt! zrTH}E*&WUUH|)v)mKWNgkgc1Vm!xI8c(Dx0rsO5LJywTwQ}dGCHmgIrsd-6WfYtcZ z4Yk+~9ThA7F2Ki57Q#>te**74AnwWzL3)iWFi>vwF?bBY3Bg1a6}|j;HC(?BKM#C; zlr#F+*1===!pU07JxYk=xGr9-GCfIoD=ui*F=9i9?kAG{c0HD!ZqsBE%fF9!{s+xz zJmwdX(mNsE_&}?8F+J`zk8P;q5q-mD=NtBe4a``QGOd$Nn_t?}fGl;xL3{|AUpzC8 z&;@D1sm$`x2d6{@>_f?Tf9>7-BzFJmYI|#Uoqh|9D7kir@t0mK3zqTRQZR&sHshgn z=~nc`bR5D>1AdWbkG9vuskh4s`cWK{g#xKt8y$|MrgZZ)hZ_TCKm@m)DW2~sKOHgq z025_65{ubrH0}9g-w%BnyTDMTdsgZmN1g09kz(;v1M&nCmc-SWh*NuYI9YX?ZbP}h zIhlM|v8y+9M<9@*18@-vp+g{#N6r%I=jEH(T(c9wuE6uR|1-~-o<|-H2i;SineGzl zK~;meaRj+`=&k%ysrx~?r&D>-E>C_qXE#>M-L`qOi{Zz5YPYqvCT3tN{mZia7x0(F zzgWN=%$ajpa~#7en(bPK6}Sdc6jdOhiAt@M0)C}LTd@pF@K_y-!nJplxq+^~i42uh z!jT0iHvOqR{8QlRz#iD+Yy7zOj`dH1Yjroh^wXnSZ*H4+;bB%Q9*QD^VgY|(HsqkL zXQelAve69(4-^6q7yS}g2{K5%57-cghl3ZG4Rl=mzVa$dx%P-hCvj}7e2L|cLGz^r z`uB0B>k8*)25r1a1qHH5N5!Ml>Z}Oid0|!8@Gz+TIChV!$Xf`wT(UjCdl@sTs{JRo z{VuksGO<;h6_R2$%Rd)4!#cC_-Ti*}s8w|)OD}p`y=G_-r9}TU~RK>#?QA#fVx>B97GPfW}oo1-zBO`3pb{F7zdC#6bRO@C{G48Mzp`2RTB{}bZa zmO7a>Fv?lQ;IKum?j15e*7O^eJ?aS7_|8)(-*Y{nuYZZVc0c%1?i+Ywhi!=&U2ahR zd@d9Wn*aN6E#T(q`k%Q1v&3e@G?rx-&qk+UkVQbH=|Md<@(qD<+Z$4$!0o$aI0T-?62F7K4-_rBhPNUtOp}Q)0uhs1y@(-&(5d z1N2Xa#PH%4EKh|V4~4c=+v`PMuGFt)>izt0T=)g=|8%t5m9R2P+!bV$@SV`w{wNN5b7%PFu3WTq}Bid2eCdaSpK zhv1eGnHw?EM<7NNsh;mO2<;Z_6<_fx*J3FA>&Bd7WV6<+H2GdjstTaO!%^+v-<>m^ z*LSKwrn2pIV8HL{?805ip6aTC)!$X4SZ6BM_Ev6nAgqM-@D&t^$2)-EOu2!D%~9ik zA0nFzMZ?yysdU%!>!2RpE*#808vB(_;oeL*fAf`oBd#G`&bN`W2V92+^)9K#o9;KTN}K0tbe02nPH-Y`+I?7yv2acl-eFzoNhrAx|!z^@AMtOCXg-Dpxv1 zBci1Pz8T&zf#p|0eY#y77t)0XvQAlA{Kh&uEGgUU)*?^*P>u6JUt7bEv)`pw zLE0-pk{uMu_m*#Rz^?^wuAY*NH@S7eT<)L)wMF+!sGwlMj0F>xhshUYBe0!(V!!u~ zL(MBDcYglalo#AOYESh_aPN1(*#ItH)A8;ewX1r?qsz=r)D^a&S2j zlSdl5_AUPm@Ub_yT?0KT&}>Gb$I^N;t%Ao`jum0$K;`KrAi^O1bXCDfL|lJXm<2?c zdpE0fCBW{_`1lX+KNrV;0s&^}iBMVeO@BO5j)J8QQ_OEX32k8>hJ6CIcN9ShJ$l;F zqJJEDavi&=1sr*S-%v!!6WsX8CHsxW+E?oq4M7{Em#P$AM)iu~A6`CjZuf%~@{-nq zd!dbv^QTC2f-t(WO&xxIzvQ2L#mPiD6LDCe#9ecOUJjZ$S4e`{(dFn0HW^?Ii@!~Q zzX)7}(?lrSPM>~NyR;%tXK`LZg9ZyMNU)II!U@*v%?kRQc~Q_)NFFFs?J*R$?r%{F zIu<2UI#hJ8E^3g#X!RsGMad2_syVj~S|@Gm@VzjTn`eB;=3;`GXU8jePEm6@B&N6L zCq}#4$#|($UtIQ4r0#vNIW;vQww$;zT0I?aW*M4wDo4ZR<7tW&s*Ui4L^K&xb>X~q zP`R;)o%*G;x)5UGUeM@#@2+bSHAn9`xn`Z@Mgm)=_VO8G>GIs%S*Mm09zOeOvRSie zySwrhKj3bqsKcNOhQDzhmD2@L1lBreye@Z!&!=JXzcWVX>`b#ux!Yg9 zHC=ZBTiEON>P_k#_MQ0_YGki6%@9SCBdug|?aCUvoOg~67Q{(W20XvoBjV(R|M^Ph}uVyz4SFY6R>nu6t+5(&F0=A@wk z&IcXXqbQ&7g=|cX!(VNIvK|ym)ggd~u4C6T4Bf3kUJWzaNojEXnl*ugCBN7N4*Ki_ z4$ABVW}4LnmVeXNcepef?{1Si2eHGADsVr6Z7B#a)|;Z>WT-2zu9LRqw&^ z!Fitrwki9l8B29b{E}8^S_0b~I{PzQFcnG}YP!1Lqr)1(F$4+NKmotIJlG(twC6PU z_&df8o=7n)mTBGi~iFHI&D3nxi;U ztY8zEg-CE#>PpKTDUwVz%cP5$jxn^S-?ic$Oxr#XM3WNmg_`)h^WTCAIE$x_WDJ zOzb7iIZ$SIMi;N>WsRNZnYn4lvhB9BI} z5G70#`*$&;2X)-NgUnv8SnZ&wLoJ506xk^rId|Hck2O)LL}1JU0xd@lIu33fV{y1V zAzw7k8~3iN3H#{r+Q>z#&=Elt)%{YjcQv`Sjl_**HPr+`6j~iBMyqk#{YXEz{od~I zhv|V(O;di^VLL&UK5?|pC=l`0%uc%7u3Wp1Q5!cSPgzEfaPDNCYFzH6QF?4Gx4v-l z@NQYp^Wmn}f-n(mG~*RP)4EHHeR>dg*Uk~onCZ<$>tc(uM~a$f6x%s=M3IqYBGehJdl!VA( z2-C+eEUyd`rK^!e5sQs&$}?CuwE%ZJisL8@eGHt-EH`YWBm`Um0TVln_lmRDrP6sJ zEOW0Z5fCk^k^oVM5#*vg2VDU^)|7V^YEmMk&Q0e_dbq#uC+2+IVk^YtJ!#>uCg*pBxhhGAW>rjz(gcv%@Ly2Z6;4Ui`cZ3 zV8Hl3SM>ljVMYN)jtRi>+I|ITYhp6}z`@okqnKOTLdydP=L8hN%;p ztHHFt;|{UQevo)YvEf+&V$p zFTo4-#irS7vZUB&wL}@8zLIKy7%u=RFs9mHTkvOJ2-HjZuW-!MgAJ@{^ixpaH6(gz z0h-k-pMFkz~;X%V8(rm#$fm>N?pbqcg+-<*!x?AZf$O+91L3|tk4X~b*l zsdoPRyl;l!5M+M}=}+GGsdL`Wu{RS&BYijYO?o@?XZBrw6bYtIVA2aY!V?4pBzLDW zFl=mVMt9Xi^P!-JtFm9&zG#@(-9JRdK{mSbGjnd`0y>7amFf}f-`s2P#>v;0con=< z3Y;$-;%LX}T6-QlWt{92b7ee_y<1$c?4XL%Cm=Kg^_a=ux z6Sr=YTDxU6EbFa=`=};4wGr}nr^hCm19Y*hD>a9C+{I|9-~CiC^tA; zK|xz#AtaYerLtzFGSs7`xw%pc7P@n8`yOd{?Cd%UvZdE=TVk;i6qK4h>~0x!lIFje*GqVKwmpzR|~EiCW~3% zu#)l=3n|2{u#|0h)G}hybhXH7syI@TI6-*fjID9^c4hYFInMUsNY{%^#AGhfWpvON zRj*O(#1LG?^%(D=*Tlk^x7+iun=Y%>=R{1OZ)i01Mx)+fd3gXhoUW`sz2zwgW*56# zZl%#g?FmH6?z|kNS^+_Zs&FfLoEbW0DnC0)8I7G*D_c63MDZ;Kyq zjnwZp=I2PWx=(#a+TMrgru^F z5Pyd?!wyPb>WVbAo{lzp#yelAccLrbncEnps3>^!-H4Eze#74xns0)*3c)*k`~n7p zL+~r8E*7Qj4HcvGw#w)G6}`)1eARfKVi2P%8!wUc7|w^$*D&+(e>Ne7k}z8#6etb1 zjeyWHn0m}4NXmcEz0~pf~jfq9417^|HixP!iG@)aZ zGch-KSvJdhJr8WItWLEOu$X;5 zd*`kRZjg!Gj!_c4OD2kqq=Z&~r&P0dM>2^-_Ew^Tm~e%qWCkz;@dNZrPxB9OcmfN( zxr|?-687#pe1M&oVOR2H;qXfWCPk*2oq3m@TASO#yaMLLM0i-l@#<>g?ant~)Kr9! z)v+B#DgVP|0c?D4c9yGNsWWUu6gyLGTnIwu;@iG$3OjtsvGkD+6k$bHpAFJHpzrXp}eXfTk+ka1Yj^UMEMsZhl4}`)N8;fMLjI z%53QpCzbaYG;5)(hFe7y2kTiBu1xnhD%N1~`!xf$rd=3=UX!?+FD9z}Nqw zw|5G~c{>+x zZIGuNyP@yg+u3*bT|J63fpcy6)!{yrosfoSrXgh>WhIXI(P96tu8)rJM;V7SM!Z97 zN_UGNyU!@B68W@a1$-o9uzM_e`|aZ<iGgCFojsc-}L*q783j~IgPYxSB8`MvUZ4j zN&KT9K9sy)VqTy-NQPL_ULA7ehDrLATBmhPCKL*s&{qfmcU{`yF?T`z^=@i{bZkW6 zaTi4D*S{>8tolwK8nCC@u0sIkX>^2_jK@wGQmL6ZNzx8ya?$=i;vOoeMU15DP22r? zb8<^Bz-DJR-+MalTq%Bd^L{m8>+`J-PACL8USXXA4(uV$wqhEndW~Sblzdv zf_ZahG|Wp+-sR$Ic4-mQfM%}RqdBZ)A?6N3>%vXM1{4Uf(zKmGfFB4oobxnc zJ!eF{--10S-g-5vl|l252f($$t^Pv*I*ZAv*;^IsmDV{Oa?6Ecrl^7CKSVEw;oB3Y zeD-qjpVRj1`Ob8@hzy}b9DY}~7_J497~Z~7?l?jFSLV-HVMy!EPP<#nG6P|Qw~VBd zP9>C$BK;#-W;ljEFhA=`Urt#9XdH;T#X}-==|bj23ejlY@)yPnG?Mr_<*3V(+Z&74 z%Rm-5-~@jnF>}CW_}{;qT>!H+XV<3L)M&U%YVELYJuY(Ni#IdAD=jg>;EHoTL|SoC zTYlEk^t|-#+|0(Ojf!mb9GWw|rtegA=P`qhxq~BMS75(PUPyd}%O3SSP9tB_XPCzS zy938-j%A0t2v7dOP1!{VIJB|D@NLX3ZbA*m+m6X(q35?`k&!9*x%rRVfef2QShv?t z1BeQkpv6>xX(Wj=^cJ6qq$3xsPM_ zCETgRGKIL-o9;#AG&d*LAE4^u@#ru=K>e5(2p(m+3rYZckss4StOEDD8)R4<_mFOz zb|~NOw!*>)9ilU;m($ey!FsWa$C-Jrf5;sEJLs5rXNvdDOMjf2=*!@pBKxcTJS6Of zQ0L5o9oglx5#mwGLPU?>>(l0N0T|{TZ?rpcbkaC@gnk`5j-qDn8DxzuwpzWwp}u_~ z#L)$9RjXRJ{Awqwp&mbxmug3%4hKqavzV=o&Fl5yp~)h>2*pgodo%+edL#8vh_wiL z=zGsQ#m-LNl@)JAg)uOk4N}psdM{{?GE8QbYO07fN<5lPK3V89{A5 z1-Ccua7iUB57{@IS*VUv11w@IFie67$>_bS_J_J|vL69JgPjeD9~M+$%!_$s`%dyS zO*fDsgE4XS%#0!?l6{ryds_-k#IOYuP=KmX3uGOvVyMi-*_}C#NHr0&TFZ`{SowHH z61ZCIFmPjl>n28hTxE%3tf*a#*BN!u zo*eZu4o%x)8V!}zhMG{&t)7`(Wir|#oghZP=+nQViaXE!fL8SkAqZS(zGz!JMzj06x`1ObE=m=R!T0*r5Uh=%Q; zqJ}|+9VT&7Ida~TRy(%1&*W9Zru)L;=NU}8La9F0#N zsZEVgdVSh*iz{hbz-e-6Z%M{COg1VIFuP>7cI-~)X;#=Qt8n=^2g2;Y{Ii4ED5HzH zCN;x2B(1PBSsS({E4(IN9r=(HY;=Q3RIJizjj-1tzLtA<%kaqFT?fZFGQBt^gG4aU zyPGMOz7IjC10$!)&qHm4=YG*+Td*J-?*`u$EIoDjJ(+vY7~7;;*GsC{nn8h`%{;<0JT;#TLC@ikq!WsRpg$_`Ppa>BmvzCS zPm3Ykzfc@r2VW84su&JBIJx5T?jzk)E?7Zqb>%5%nYHws(%n(MkNoE{8 z#&bV^@Cqx_mkLP#o)?ifmY&xnq`tE zG_X5jcpB4#F3+F8ClL9fd7Cr*TMkE82DNU=RlGoP80in;peeCw^;`B5++?Bl54SlKd<;jzO3~MZu zXUBC%ocS@FVdv3!d#6(G%&WgHy9T`Ro)wNbo)^~h=bFMRDmK#pr`7rxta3W;kdm^R zY(WaN!2+-lISpy^*G*kyvqvwctJ)Hto1Ac2^#c=RYO}4Z_S3*ZCAa5fF51=<8fP>Cx64F4BsOzres4Hrib9SWX+glY zq&*CaHB=?FI+I~vNiHo{Xc5xMd4zV@QPPB*Ihh302{CUdbaQ0Z-(4zWKl*HT&diGB zk?Q{nx6VG$>b70_*%Da=s&zZhp+>JCe-OLeb^LT?`($#zcA; zye`Tj>-Yx`EHAG_W!&2&yB5ygFw0q<{M7Usg}PIjGp8GI&X5!Mx=2u8!7U=VsR0Yo z4XWOq_Ro3t|YAiC??Sq39)PUwn5Cc zhI6SnC0q>5!v-wCyn_~X&tk=2Yq?lqRvBHq}Y}tO;VW2vPv|K`f?)ju`F&1WD(TZtHQ%l zg|BEn$z;4?Gv5=1HHoPkGMlZeOThKmjKk4(uqdiYWGd8g7sjzfy2&Zs_FK>n`FfD1 zde;j?zBI68-sa+Wv=#+m*e&{@Mz^LkT$9>cI^j80cc`~`pw{RtQ|j63dKj5h`Eaa= z&h$kOc3u`;0~}byqZfR6!4zKR^E(nZ-qW>yS5?tH1ag8%i$vcax_Oz5efDjQ4A$M- zxx?~0dw|HLrh%ntY@YuM#n!woNfsW_O&YrCW01( zT8LmSOb9@>j8Rik5i#kSPUA>g6X}OdeV34(%#sP~wq~_6JYu!Xmqy)Clf)>x%~p|) zEWbBz+cCk<04^qW9xdd0`$ks?7IXAgLaMw{=A2FaT$>BElIIT?@BzK1L-lCPTNxXx zo8KsiV#;vLYUJ0|c~gboD|p&bWPY2D5_HY@8~9W*P=G+zc34-{@wgZb2B*P8c&|o0 zUn1`t!Ysc$xuEY^I_Px1djDPH%V$b83$)v)9w@--y_ak)#Q6R;m&(g2*;qE^ZK%WB z`SSFqzO}#oNdX;gSy;w90-YdwhuMZ*26pnw2~27j1#sMFjt%&wiwRp)g%zv{DPw#!1L@@Gv=YT&lB#;=~&i{LI2f8kV{ z3V!V;tHNEBesVyT@ONk25;IJRq(rWzBCuQp#9-7?P^i92+Tu)QW+^XA2}LMy0IWmM zqi!NjjA*5)E23=s1!JqKoAqcB+QB*wqFf0lb}}}4t?mjxv*CnMq8dZS8e+l3V8Z-t zSE?6(s}jwLSBh%oi(|Ge$8*I-r3Cr4ctU{y4;B*;rcIjR0R6IC~ z7`yVhQ#3AwG86VFmCpO*opg(mm6k3=V7WyS*tce#U5IR6$3`I!dhlA?R?A^<0LaEG z4JCnN8I)yhTcV~ZlrkQT>Xa1h%3Hqq!%cRAyfl z#<8?a)q81@;W=0+A|5zE0R%kgcXt!ASRKi z3nT_nK7>`fIQ79kXIxdqMbMSRb>f;CxUI}N z3xq5T|HD~B5_fCJuEe>3uyne1*(+jtYfU5?t*uL>^D6>hP4CSRUHb3;7yY*hK2$-9 zA+bS^7eOT4NS{XhVF~c@5jao8^pty%IFZG)E)*k;3Us)62wndC6SV8)=g_Z@*K!u# zUGe$mDda|*VG@W*P*ahNSrhx+Heq4R2v*JtYON~9h#9fLxlt9?jHO_2Kxk@ID_dHa z#Cej7B5cy=m2(3G7X>YCD@*UhN)lEDR>4R&9a1vz$kE2@ZEYVxQ1sxw;OaLiCn^WJ zI6kD6AFuvFgu_Jjf17Mb8`z7dIaqG41-p&(imeqk1!UDg(0gniagcd+1axnIW}Zf^20Mna*M@yOhzqYF~1f+KKJh^D_+@u zYTB3&x`(?pZ5BZ)!{OB%iYV5@UbRiuUN{&RMReRB4zhx0aF{GOPyErqOj}n3L42AJ z(hLtR@?zvp@HTHe&fTO0oL{}P&3YK8 zhX}kVCbilX)o%U11+yOOlGNI2j5VkmTG#N#GQf3RGBXVV2!{ypJ){mkke8-Q7>bxo zDjrW4B!-hgCmk?}#M)4b3Yeuk_%g-GVzbPLwEpM0%HQNpgGeav9M+`=@N|r{Ns2jl z5bQ(i89K)i9kHCHRu7b+aBNc&iDKXF%)dLHc1ds_KO@4 z6@s%y$0?H*X%j=)Y*qhLcZoJ8*iA)EW1776_%xllB&t8jj*1;ONm9ujc=a#%((}bf z%bahC8xzS=;WNp^OLthrm7@jv}+y?p`M~R9iCjje8RGf zPfruEp0MLMox*kStCzye6qiS;`YOn*M6(BNFBc>~j;V_f2tY@H0Pmm~@=e5c9CDdn zLBoI(Y2KJBC&Z0NB55TyN#}55zx=eA1XjyyiDruvjWWX^G7~P3AF(X9Pz+|iV8Kzd zZGA8tK!TtGYhf*{TqTi|oX69dEp;19B}bW^V(i1K{<9KHDvZc#`*u|AB9FuKK`@je z+#krz--Je41o_#t+#`dS@UR-28R$tZa1QJQlC*-RQkn#h#xz z>cS07=lN_r9!klJ?4@q!r~pVA4Dz9y#MTKGZ-bPm^qEe3OZL6a>la6`s|&5db%R(H zu^#9!pKb44{QB5t-aj1MpqWh88JK9 zDflYoPSQDB&Cp~Q4p!M#%2f=wrUJrez{5n`Zex1rqgThd9 z`ZfrtLL%zs!HAiBf=;)JaKrFLNz_9zM1Dm!zIw*7bVJY_AO-7QtrZeY3|dKei+;$r z5JC|VNWP+~p&N~v81tlB4Se}|F$`UzAPtMl`7JVHB^OiRpd-Wti7hK@OSpsA!L4vB zT(wHaNiG-JtXuqj>-OF*pHL+%)T?#;K?0DYR*%SBK?jO zl2aAc7(#_FuzK{wLJv2&y|%QzV#D(KsCqP}m|n~&Iwo7Q)~I4X91q>t&^bDKw?`vl zWdwM0^BROyY34ZIqsNC3fio3aQ{v9H%Z6usVMcs=vvHdAXg$kg4pAHrdU+>dE>?~o z3HDUZuM0xSvJa8DSURZBQq=v9ERib#Djca2boGZAlc?L)H0-#AZG#t2r&;N zyPCD)ebja|MrqEuaU_{m!!_7?UbqHGv1))LY-k{5X*HF5h_zUAdX+~o01)NU+Y({H zgYuJ=OB}HQA!7zZa4Z}PM;uZPzsR}ST3qQ4$6UEkc(hy7+1Y|U{pKp)DBFnL9W`|Q z+6=MUSR#G?>>|&j3TwY-*bYJgLUPzpJJI%*Wq2uobJ+4dVgvoNhEcTft};bI%nYQL-3g1TW-mc+Yr4T`8P} z%$CLI`GCG}DWw(T3?o~b!Y(LjrYS@RDO@dRXK4qK(zNJjN&4be?kdpb`>2!Jb?gfv z##p<|8S690B_uldgN<@~Yd9DS;)cEVxk=*a+TT~GF|^!80)cpORW1GB|M@@fMdzhC z8-wcQ|G5W0Z{8|Uy8M3*-xmHq4t&VZ(luH^Bo+2B@ATY~rK{KOkL(SU-bq6<@802c z;ZrqcckoMlfj8WRk38m4*VHwPbc_bReMt|shARx~t1YshXXxPT3lx6Romx4h{QiPi zqudyS1V&>JQS+7!8pBx<1L=s!3m_PKFpKI641w($3Tk1lj!K{mN+l!(CHfh%nrPTk zyu>iCY-}lUdc&rhqiJ{Q5faP_qtv1xqM#-dbOi_zK<8?4Ocf1|GI`WxmLzu*?11n< z2ol_Ks54ZdVCDPPtv(nai+hL3B^_eEP^fU@)y?j5sS~B8kVWl+C&P)wNxc)5+%~RI z(l(Y5#b}d2gj%53^(Yt?R2opWDqYRa<)Lh(-*oB_uI>A!tDetTtQ?^=+HFwAoFJWTsj5u3JGN zwqYEG-bTyfKx+jn{ZvSc%`}xgbO&6VXx>v5iMphvaWine>6q(7=aMP^RcFIW$pc}5 z3^-Sz5uB3{2j)o#0`h4P9H7=}m}7zdT9dGpd<|_|5Z-^r!o|Z^KPwjfvvEDM_txwz zGCy_)k8exQ=;*cJEx(Z!CBhaZExpoH$cl`ks)~+!!dU)QJ4lpf-|6ORMs^igoo}!R zjcN!ux?Z@vYKKGFa!+&p$erd?R(ilT(%Dz#WJb!8i6Ozy)R-$OmJvmknE7ae5OAp! zkm*x~8x_D&+XfCv+fQ7`_#Qc4o>Y2YtGk- zbVWfuIIed~?!{=>a8h$jt&cKN>*8o~@=$m)zI`(K6m4|KO0AkK?jOO1xGz<<#-@9P zNgO#$6O-GsJ~?bUV`qx|g|v1DLDZJ@@AUOT-}?Afcx<*Rx{}NjTdSR_XdP}R)+?jq zd9^dI?7S*&MZ12B@tBeuN1NG{QmvBRRNT;ijiUG>WnDu?uXjd?wRLUd=azxR4OQbk znx75h#(F>1zlx))Ql3BvglJ@D$)@=vh@pBO>4kxFatwVH((14BUl@gk2gTVB$@^ zbMzwLSB{61NE;0CFY4Xb^S&CxVF<#s|Wgp5UP5AV0C zQvhR@f^2fwFb98OhQJGsL}FA4$Y$Vp{YABMLyx|WiMppq>4HfwI+^ z^s<~OJfUNT0=9cfey;XTf@0w&PR_;hN4X<(TGQ(Abu)!kFA0$IjEMOD%jb;9`NDA* z*5yk$_R}D_arKhpNSB#q8UDPp*7OGKYo{|0?Ck=s9`a4{!vQe(8zlg;AnG`Vj?kD; zDWt|Ea98k4qjqeuzI7xOV0c`IX~M)a8#NWj9ET4iL7<$bda+ZiiYI>$V5>=J18PJ$ zfO8k(YL+XL>t)E0lU(~^_oJa~TY{Bf2uET{_~>(ZvY*eW*jCSX`?R$9=9!9W0RPvY z>v@!WgW+KNg=Am1ewn;QtKoSYq!twln}6%eW3eGDt39j?N%{X?yP1qWd}t2}|68c- z_X-KezFmH;=&LoC#NxnhY39{#|NMNJ@=F9eu~n+wBr7#Ew%Coe`8 zJQJVpMuC`0@aTf=?DYDhvB%I+m=~WOptD1lZcmSyyL?qDo4wkO zyV_~#!%kXQzX9{8UznvM`cVS%S#sdtlb*ACr;n7HR~Lk7N&9)X-8QKFUzQcUm%1?& z+SHALTFoMDr*y@u{&^1)E(k_kx&-Rf0!*)!gs!k!>)m0)(5x#CQy3xS^~AoMBMl*e zBZJV{e4x^pFrUh{u2yMKX$3q25{aH+g9s?EZ1d9r^AM5$I76WTPQWE_Dg5hgqS5Et zFZ3hTc_}l+Qt95w>8{q+t%%Op3w#68?E+MLPbQ)e{*5Te#vA>l_je;hXW0-!h9GX~ zoI9Agx{U7TT}gK6ULojjME@^-aP^w9PgYkF^LR4(ABY=*hpn#*nFD(#b3NsMeuwW! zK;0{J`{{7)gqNLR_|h=oYTJla{R-7}IU>p^^@8-beQWi4RuN|vgVaB#(LkO9h^U(Dhrv|la*4vUyuNv&?TR%kh}q^M=aj>B;Z zm_u!}VWs}HKpe+V>N-jY1gw5U2AEm^!kvoi9ch-x(KW*o>@i8CKw+e*k}21Qq1@TB z=2DXAau<#+qa19@4a8cGQcPq_9kZFHwCV_xi}8pv$X3B;Uka&Bbg)V~aL;eJgFhGz z)Z{+TCv%FCc$%*XO4U>1l<`%dl4nZmzCF8z%Og(Jo+f+otJtL5H}}p&Btf*r6{R z^-w18@uXt51I~7WG&{66r$)bD!ySOs{OeeyFXvX$YyHLb>i7Q?7aS z>Aqv1^}y+_LI4djWppMVfq-hYS!|8Ay zw7{@qU4SBQgZr^4@xd*O#q?m9eQfk|)O%@4`d=KG1WHk$d+Sq{PnE$e9plmz0I z0J2S37X=88s}s1R!PD>C`nUi6!X1l}4Q9o(8E5Am#$f6FykK&J+*~UmbQh`1-zr~OJc|Hccnc}=vCnRT-PgH^Z(1`ga3+^W~xOoq>WDZJw~ms@4Y>X zDV=MVoU6@B(Qbd^_EnGfuQ+6g0$AIDSxPCyD?r&kYk%e0p?*eNe(sge_Dc*WZ~E}* za7VARLYVtu=o%YUnXgnSJ5^1dTF|Ct9$?-KtzD2#3jhkzsVHXxKCMijrM(&};Q|m3 z5PYqQFg#^dH!?w3KX&G!G0o{-_bq5sFgpbs&I4{u3GvpRxTlMnjz@`IX+)QkcZHP! zA~*w4p)BBym^KLjk>rK5C;!7T{J$@p?6aC&-^m(#JqC88a)h(dH^x~To?IBb1z}2i zZoi_m)kZRtF`LsI6=46-zWVCH+0XO--=+Vgl|Y93UqG|5RoV^v8xY(*D1y5J0@%_I z=D7*A-lY+b>@xVq?0_FyFt-7b;7nmMaGG#V;8Ymio-$s4GQKgH(RDw^8|U(#5C+^{ zd1lY7@3qjuv1xVtc#VsVG}>T`!)7T?ZSgZrsE5@W$taw!s11v~t+TY&))i=4Qkv`r zan4m!98WJ~jb&Ugh|2KO<)x6RnokXGG$1U9R7q8B+2PzXcV-&*eml3~lR8~-5-lHv z2d*Z&C!I}?uTCOhs1-O~%K!V9&>;Per*CvaMq3pK$n!miwSQfLoCM33HX%8>$sUrF z=8p=ZV|KdQs~lQopnRW5psz$!-so6nn&tsxTWBil=f&!oQW-vG@TDe--?Z3Azyori zw|j|GJVS6LFk=y19&jEznq+0$&|qpw@J=POyG(SbVLQd5H>c8Q{^i)q*CWO3uowdP z^{DkKwUb>Yep{WHGM57U@-j)lmWa;aFsy@%x-a87o_D+|PkvN5+@Hph?uANX*ZWq@ z4G>Y_Tk*2YTo@<#l0%PLPaE7NQFIot9sQ z6##k$3DE1Nd3DWQ+zbUqD`3?|qV(+*OxE?Fmrs0+6(2>yUVNcIY9ybsn}@~LAm zrMB#mHoF%nvYR+a^1!7TzM=rAYE9D`PWRg~8L?-Q`2PK;^)`T&2(b8aTn|H(zv z4znYgt*-V$j!r7bNfr&x?&gUl5FdYUhFpOMpY89=r(giXsGqo= zX;{Ozwkx`9`L3?^%lmiN=gbcq5{jgu^10=^W_4LE6#D&~>z1eud`B?s#BqoP#-y|) z0!o^uEln#840J(_#0QvV(*#oSy&kHbUlm)^R<`RcK9A?^Ub{IE=*o~qku1};8Xbba zbe}6Sj7x&VTOtC%+)>?ZsA-4-RWc!iJ8b32`0ZG#+wX<#JA>AoZ};_ck@*{Yo7#ic zVhN_(%e4F0^j*o`I(vb+KoDfGV9&1+>f^t2<&WE?_xae^Rlfi^;YaK4y{+CoH}0DJ z<~ur@s9)lE;5$eu3IinkGjp4_uod(A%IQ|XkG;EFps#xZ&RTrRarQS|674t7bNk52 z%W?KMZeu;~gO$aIAn?jnFX$_uSI%QIF2l0&*e4#4n zvZ>`YjA9wHy~*@OgFlfOX+8HQLrYC|m@W%3-$cGTli_YvUo5KBba^41n%yz2cAHzT z5VWa`CWu$E$sgtL!#5{m3ei^&oQa?Lak_kezLGTgjwUOJhCJmYgnGkB7(TToM27x) zZe!C({ImnO$6APk6DpRa<3zetLn33pzQ6MDyrxfDGPddYH*_`$aokWx~RLPQc(RziOxP;QGU`H^s~MPrE4;;i;&fP1C0%S4$NW#l3l<*jwV^u zjwst>#WR%RL_YspxUMLf-D}041x#KqC6CpOc^j%FC#Txm^fCU*cHO10E!e{3@|$IP z4SO!?Bff5pw{}rgOl5^@6l=2MrXE-Qe6yWmBbz;D!!bAvCt)29Ir;}JSLkhuQv30} z_`9*&Jau(Nnpn8!=65!Y>{GzP5q$-|8-F_$OGd=*G`l#w%`kaM!dNM!*{l{GKejL? zqoge@Dstr4%Tl0gN?9Vuo6X1qGPK;`>*t8KcG`@L`<Rkc^e~o zlfwC15XKP|KUy={SYlT)AEZ__Ti)cQOf(&2!6;jm>!(7YhLCZx&8g^-Gg!r??D)~) z=%`J82PT_NV@;)8b^V`W*s*lP7oh0o#5~--)bjYkscu3}1OdvchEgg8>ZlQDE*nZ( zFS4vBucvYj{=14{<*CBQo~@RBe0(yT&VxbAaM7$4TB0|ih%Mpm)q7W%dN3F+&Xy{E z^W!3Iv~)MWyZXp_oyXjHh7?73C(mJ`wg^ZmwSJN1#hN7AiV%q;QJU8htLtg*pklbd zV%~2wGBNPqlc}qdp@v5f!z@Cfwo0rc9*1}XZ6g20g`+;+XJ>9+HjM@!@*E7yAn)+Ep0pumUs?w@dv0z=lab1Y)b)QmbNvUiF8f5~jmAH>K7M$&*{~1x!(Sf*v%c$UFlv~z0CT@JJ4vfzu zn3Q2Q054QCk{~di!l`b?=pffbN0&6|MOOA5h>=JgrT|jT1jr{}=71NK0KMQ2_`w6Z z8TnmSYLBN=3kyrI%`68Z@dl%ikvf!>ZYs7{-g@kKewtk0K|jUuDU?&+-E=~Zq$neE zqG52>hgz*^TxuZz9dpq+>0_8kB-B8gO&vhT85H0+I3A9JyszXlwsd@NS#fxcy$POI z&9px#@Syd%gK6t+h(Q7yi$VD)p%A@4)QqYCspl3&)z13x5{<8_ zLi>^3mZv+JhMYLI5(J2IkQuFE^QeqF0xjB3C$DJkLy$PKt-OJ=5{4 zS`(;Jp$&<<+sF|2J`b8?9J;Ad^pu+2DLTrssxmKur*Kk$nw{8DU{!CXX0%oIl*hOF z@t~VsZWi*BO~dkLn7%T7Ljf=0gQK&fO~?RsxmoCDalK*5B(MRjj#M~ju&Z?zYe^zY zKL0a%krgS>>TIeVlh^DrkEpcNJ zoCL>IObvxi7G}bjiO$nF41wq6!N^R^0B+h`^b?J5@t^1{T%)pmt{hn!Ib^Q}2Lj$ok!IOy%K z?~}uExZRGu3T{+3TZflgm5ofz!Y&xv!j2bRRR$%C;x&!5yxDjj%nMbC4hK6Dhe_?` zYMoh^Ba~V<+2S?AXjq$ofa;tloojT~th12fclc3olEkja@bgdhtJCQkbr?TKMy<+r zCzM(W5my7u5X+&I$A^Ry`^0hHE(B4O?HfXT&6Qe`c-_+_MN>L zXZ)%P2XGw*9E8zYvn^6DvQ}4?vN14xw=DRa8^)rCa2vL<=EWgb;PX^0jDNz8T;!c|C>J6|42J((TOe4I7)QzKog(&ECNuKpwca@_nb2uS%8?E9$J26)a zrk$c^;8qB?!)Hfut|q>ylVy%PB`k`I8z_sS(>v++R~PCYM+nY%b+_`tDT%eA;4rpP zQRP`Xw!L;1M;g7|=|+tzN~hV>aRwSREUX+#5Fi6V9;!8ex}{d2;aGtU2SD&rV!;4V zTL~~T${a%%TEuc@>4u@I;@J+zV-_q2qG>6d+WAV55-btOEHJ@+ttci`+@PB4PL_tb?VgjxAuvOQWR-y+c}gdj?29) zL_?_U+x$|86WPOq>fY8j3Rbba{1VWVC-ZNwADUH^J?E-*bx7{nm+anrzhg6%jRz-1 zJ`<5u_J1WdH zLkB(rIzouYupCP!RajOrP!Xj7eX%9tmFV=kB4(>&=wj^uloJN(r`i=T)s(i?RGN&g zr>H5mGih=wHMxzL7`v*6#RytXCW|BrMg!4#j3VRykl%wljdcaKK1m(Hy~gS&1oa6F zYJp7KwGCn1>WXYtwR*0#6->tO%cfTM;WVV%Tj{B+Aw->>^$l474&y zt(-T*9JJ|uSrMK{RJUzQLY2onPIw;WO`d==;(3pD8V`Oo6v+|A)fp6sU^cB>61zl| zva4AxbMCwnBJVq@u-TT@5daG;uz)3?3LSRJO~8)V2+$gbcqDEDVA9)5!Ih{|>$;6i zzr2=k#CzZEz#-s`u?(=Y{EgJ*{*Z0Tl$%a!JZL$MH}TnQnYfaqXl`65sD1%DSEHGQ zDCn3?Adr;WL80r_fA7zg6`E)1s!I4pTVzNP2+!f$)}I0Y*earbItB9w zJ0cbM$iCb^Ot4}#p0apsF`*M96>U;S%!Te{SU6=KC zT&^+YZ!NZg7tW&)yL`tom^=1BPy0HkCORWx2wBuD_ozkX%pV|^@CHcqu?#`|74D&# zb@QPtzheS>_76K{+7vQlQh<_E1KenYxDV^Hx{jaWL!nO3fJ(OI3X=R9SXEB`0B=fm z1r&>5%OMnr5dMzaNVciGw!{C+i!=yB^d7iSBZ&l(t84^v9#bc;<)^p#Q(f^@-KPnZ z#gicKbEJ?GShj5kaJCI#r}h{PMXg;Y1>{7|bUi1db(#yp9)lnbo<)cZ+yj>auTJqo z+;dS}`GVNWqS=K+*ZrI`Y@RuDQ}taKfw9lZsO-V(iwE07h|h%XQ(PNK<}vo0qkB+o z_RpfyY<@VuK@)lq%wUFq3FHMZ0v-s2*d`+s?$~}8V>{5%v-Q9+Y!H5dFq>pkNYGf~ zxxH)>rCi)@eYY1he16z4lyqdoT6!`U1R;E6YB(uran#%Z#f{6OwMS zdqMq6XeQcpm0*kW^yoqiwMI6Ad&A%q|HQOa5K9~aBsQMrfAp-|0rx=-2Gz6I6~vER zeC{tDeE6}-$x`z8h1}fS$8~Q8R;6e6|NRR_+v?%bX#v@tQPXBc8G)v@;;_8jZ0la1 zteReb9$wcJ%c^dbv!E;PXldrMT(2j~kbyCWzBXT{jrSgjZ$n)H?^9UFEC=2RQ4)}) zlX#9q&?fXcHdF2rny1Q7pY`q&U*dqGj>nH9ow;q~4k~cUN~KOOm>tI}gWWTp9aufK zxu~p|l0s~IIH9=TW}HRQRc|+TLBbAiM*Fw%CQ)abr3lXV*>WXYHK4{5z13ZC!@SsY zZL-3*=XiH>_3jv9X<6n6Wy}s6qea5(1jY#1AD0se@pYnp5$K+Tf55qsx^P=BD1`-? zh~D~bdsCn$jGnbq91fv7WBkQlGR44Dk!uiZd8K7f{xs zKP@vt*j%(iyPj{8ekj;(K4?Y1>2+{S0n$|tRW(fz+DWC6CR?P|yCJWUEJ!tm*^x|Q z*n}$YdJoonJ%J;n|yq%9D!X zHE=#$Q4CdSF+4iT!UC# ziM1F8>XxqI*$2eD5LSWdISe!$v`PX>A5>~w)+qLf3C|sKieXELov24?2lpj!5J%tkWtL#~F|Y>u3elTt1+n(21B(*u9Y~OZ&OW!~h$PX8 z$$j&rdlizCb&ru6a0eL-ZE4%cZBYg($_g)>nYi9~?xxM?;Zd+JF*P8CxeB`n;QWlt zhS`T}RikN4j(}_e-YrawjV=}I2&lxxkGxqLzhx%fL=F%<-w+-QUZ7VJpPH=7rKPjB7zc<+;Ll9AO$L@_)a@Ja~vQZs;rKn=yWHSZFLjB@kD!0zwme4r4 z)i9mg2u?Z&kCy`uTQr3*8lsrCIaA6g%maU%2<^h+8wu}hpjsAD;=AR!4pONB7Q=%_ zWn)4^PgAcnXJzDEs+Zjvg!}^|Fj}k&<7Oi~D3$*r5u$U{?rp$gDr3Bu(&pp29%_bp z$g*XVm{BK5zi?qz2{joDz1;Ct@`wn*@hVacGcc??oX%pC5=v`Pn_gBJ7~TXUlR41G z3Kl$NV7qyST4SV_EhFUm*8xBmDWSC=jGZyMn!;;lOIc3PpPf2X_w~n~$S8X9O0$Y_ZiWQujI6{Zn5g3N8 zq;-vgD2yW8R288l;u4FI5(F9|cnDnJUaJD+P7H~L;lg2L#X-(7=<-}hpbK133Z+mA zu32p(W1EVOu^jjGLRbC3OniqpGc%I3$NuYm(rewEs{dpv_*-C|p1SlIzX!GB@kv(-%iFRYSR zh#827OXJa6+#YZTzt+x@%2bh@-zrVD6d}c(+DQ#p!j-}mfi=7Nwi!wn3W$KY>4Ph2 zU2}6;Ha7MjS%x+JWf`;6`_^H7f1VyGPD5w6$3`R}F%^g>n`IKB2T@~%5RzAK0H$&I zO^?4RSGq3W*^=s{BvQo=EF|%cx~WuOICEy06vk3Z&QDK(X5gBAN-%A8z8v!` zV^)JCjvGU7Hn9UiIU%}iWloV$G@UMxsCdxRiD57};_}`e+~bAk$X(4I!3obln1^x| z5(I`?G?WmjaJb<+ZV;gt@zcdJJj(dc*KnGo9c;^2{IhBpjV^{6I5LYx z8x1jF!XBDTj#RG{u?A;pU8=)~GNX?7`< zm5OjUK~SN(>v|gMLS5oekOK4tJmWpLoEcVE2DKo*XyHPlW7a@HPQ}~AF>F`G99sY* zi{4aGJUEohGDv9Hij@F0K+3;-%JS}V{=wB!i1n8;$WH-oO2E0GwE{e8eIk6>#`=Y8 z3avMec;uhAzT5XLwb|*SWlZ$?)vD>yQidb=r?3V)Ji@G1l+DOk+$*-bnk z+`>*ZP?Y?BmtCrC2^C+r4$sh`_b=>jw$u75F^)yw+)=4LVJvpc9&zCm6ik64p)4Ch z=?6~%h>(n|zQ*CjRPi7KXIr%mK;%SFVMm23KuE#KDLV2oWn8ASptx)n<313UpKP~@ za4&vxQGepJ$%~i=V0ZvnZ=v6lBnHEn*{vso_6D)4PrNTH?Cmt@)Kdzf61WON&0nL~^P5fMgzl9S#sT-iOS>7^eM9zFyaJbrB1ij%ga&NnKu6ga800 z(1^^}Kp73v+okMXyG1o^Eho3C(iDl;;32Tz*(f)ULa)FXxPF_wiWS3m4y9Bu%sl`!N`gepqJz(+O4ZzA?rp(+eeB=)4glAXvd8C_ltpqR3rhnfa1`=l z=f6DOtHVz@2NCqjk(` zNu$i!*VfmLoKmg%^1&6cTNHQ(VY3#N$EIc#7Ua9#&^mQn6KsRo6YLa3$;`+ZoKGaD%&gcJ*xW>>9B$D9sL4D7v6)LGaCTjAGeZ8|O9kF^R}W4-7+-Mu^+ z@83SmOuzT&ExtkgReH>t^=Av^9&WTAr368ym zx&vO<hwIndaoLphlCjw2- zLULd?gw|RRgQ(yHd9mXm;OrJEkRoCU`Yvcod4dh$aL%&`2;lov@GRF3BD`=d8B=L) zpPwG9C)x(r<(c|&y@M|=;LX{F{53`Kjk&wJ^0^r}4YcP3a*n%fis8C$Xkb(gq%N7e9Un|n5-={qHvzr6%&+HC05Hju|1cP;!1Yyz@#PRz;-$j6FG z+@qObN*nNbgQnwvUYaV3iJ}P6c?79JNHJh4009IL#6aBiiX^boMdc2< zly}mU0SHD_6ePqb>tjTtq6&iC--ER~B3k%2R>kBnbxD$|NV(bdFbq?A(xPH~3uc+q z6vnS8Z>H@!g{K-@8Y=9YwkG}i7$w#V+Le9Vn~rRH)6F{LAHfN)|vb zhkTt%%5D$|F|}fqxO680BynUhqvAk9s_Of0ly>tV@1~LaV5`uJIIc-zEn|#}L=sik z=cn;PU3mQq`%Z882QS$0<7 z-qC^a=k)?emxICbcBT664YwUZ+s5&m+Rxv4%{?{%)L(j#PP_kzc`fbU+b@4_yZzDa z|Jl^`n7_W@AFlx5g$w=~q*E2-dO+VNk9kZK#Az#zolz^*7g)XhZ7Vc!I`__qo4QTR z%2gXP?==@C0QvO*(kV?Bfd=HD0vvEz(ay+pKB_4i`D#&wS&-gWYj@?%mCA>`;58HE&35+u=ii|MNqPVC zPk}q%0(k>_28x3ry^T|N1#eNZCP(i?EgcKSqrU6swTqdr2St}Dyi%Ksu31zb;Ui53 zn7WRrEecTk=!nMs^B+!#OR^XZiM|Ak=T175)z2*ye^enb%3nu+PtG78+aDa`S^?fI z4*p1`-AY86>g7;MA1yA%2$VwLUn;e*oviqE>j(QOF9eF40A-O7ItIAn8%hD>72TUW zkZ3XKEpEklGG~-H;{oxIf)124ucfy@^0uNpbUhLg>K-$1z9*7 zKG4UThDO#N9J-3og~TEB0ta@;7BT(UW#z^C)aByU2g2yq2nDG>J-mQ&5Fw|K>~lM< zfBKrNg%~otKk%Yu{FnvClje_eIL=TcO_T5wC_;-qjx^N`;#{NZXANh8K&^f~)BIN? zRB^%&W`8|}eMWsN_9CnTno#X)Iv=hbqXZ*Kek@6um`Vd5_0PwlY044tscH`<_X?=Y z<^Ax}ash9^%R`niPT8vg|Cu`TPQVd8b6B& zL)(@-2d07d){6NaAoUGdH4pv$KEuQ?|%~Dnv0vtRr#VxQI|hn z2ieTs+3?2{e)i|#^K-fH0)W05Yz46@{|vyO*E(?e8o)EPfu5U7fNQ8vmDLL9zHYm~ zh4s~M{n@cd;w#c^l7t|l*%qh!F zGwpUpF#%V;Fj`s~vF9ns*zIXd75QnAuxM4J>us}n&%HhAJ?C)G{y7!<Oy9cR zrvHZZHcwp(Q=0p+m|z3*(;HuHMJ~qF{Jy;f@4505ZTOv^9)-SXp`YDOx300}oPqhh z|089+74G{?Ah*oMTNjjn=)ah=4N~ThGiY~hS#0_;=MrHX|L)JPJNkEtjlfcT{5wBCueqVE2W4hlYm|upqW3gYeBy&ouB0%x`c((+ zjeD5C{PW`ivGxtE?>piGwr+lHZM3zTF7^ay51?)(W3#H6#hC{q)QbCAq5wBy6z3h>Y^YuT!1CsVXNvx~ry3 zOJ6{#o=moXq`N-Nh4UC1?)ulq*&>$*M&7UA-NqlEyMk9e9awaE;xR`;6olefQAQ9_ zf!ZgbhbNU58t@c=BkG3Nit8>#H0=@Tg32oAiM1t3LdUV#=)6Xo^XY`w+EvE9QgVaP ztm{U_8g!u>KLgwsuLnBL0EktAF9LZ@$^j|^q?l+{_b%VP{N?>`Ekdfm*(85GQtE`%uKF2~e(KY!+@EMvSM`!6|&{ zR+~!7e+ClQz7h&{JCU`Z8btl>#8nBKGMPXEV0Rp(X)T+UJ<%~`@Ss|@^ZBzf=sUcR zp8%rcDZEglQl28yHgvYcWn#LDS5r47dXS{1N5Uf$KQv~y+sSdcUeMoh!7$Bi2J z2?K4r&+%iE9pEHRV96QKMt%T> zEL+5a4T_)w9AJB(;xV*@mrx2RR0{}vX33h-9SIi?cwvQYP1bBiAblJ`6lW4GDrOM0 zP)GTnwM(x>`VcTxgYA8^+zBjcwnXou`sn}*r>!}|)J?;%GQFLG5pxV&airVSaWEc? z0}ug832BS~AEXVW#dNU^KJ%hj3q&;u&`g>Mua6WMKMt<}R(FC1oZti=G>e$c@TFO| z`PFb>$yNaQmqGVV1?S6a}yIL!- z-IUhW^a5068FZBf!gGVlo2y@y*4!WTOR(`d=#AMyHG|GO=#GZqcDi~n?h%k%a9pb*$YdP8H)8Yj~<;tF5*W?j&;A~>&QK{=x z`Z$-BkX#TY9*ls1S3w=>5Qa*leQl85WIdr`Ys0{A0^v zJE@-ka*2EGC&-=E!J5VU(A83GR1rC7A_Fea?1rkv$YIxs1WG0M>bd?6$Zl zq-|KbBi9fNg%bIm59XlbqYgt(-NxsS3%2>dpJ(5_EXlF1I{QcBt@JbY|~d+pq;$Zp*NF<;{_5*+}o!tX`Iz z&Y2k(7V82?Z#dr#ouN398&l@scCN`zH`fo|v~!y0>g#@OdQEEuCEA4QQ8c75(Axze z6X#+LVFFw>3jEy#C6IvA;4xT1h8m_)i3w5Y1W$#H5G0Le@oE$|(nKTCd22;vubW=m z_*noH@Tpx4GOSR}6{ygYRjo)OLvlPHk}eiSh+Z$wr8Zg=^Yy_;?~72(z7enhQ~;z5 zsV_~IGU^1AU=6{DBtfgqz3`?;nW>v~W_2}Co@15qW3zgXVV2&>oH~_ZTy`urFP}@Q zWVnjGe`Sd=Nnh&9pd z;yiQYSDJsCF`f=w3~&5Xy^H;5@Y^CyB^pmYzr@3z>n_RRFihVb-PgIZTqZ`BxVq8R zc0NC!ZhZ@VciqkK^6yixNRKu9ym{|~5BmN;)pk~JcJpKD(fLHFl*r?5GL5ITF;>CV z@9C4Dkp9a#2@q5nx&907uRZ9j*Mx~gK(WcOR@7Z}6(BjTS1@QnvG6qg_@0+}iHCXT zUDFci4B%$aTr=^*dc`XT0#)}ZVExsa3-c9+f3vWb2Kt?YIXl@rtvVf!{3lg0TiYAP z^W{^aMk-`vGwP(=%VTV?zox`P#GPp7JgB=5bML@Y%+MO7ma1+Ae2jovz@ibsUQI12 z0|h{!oRz^U1Dh(e6dy$2$1rsEP;Hh!T}}DPa46(7>ojTwYK=xB6bXl;Au5(EPItvj z1+1#^-C-C;CzUQ_!cKUbJDP}jQKM2b>a73;nW1YK1Bw%gFjx~H`4Np$j7eQpiBu9b zC!rXTV~Dv;D$prjG73>>-?|iSkKfae;4mab(xS%asud#rUPt#>eqm-#fs2`P{Ahbu ze=Tb+Ds$UY#dt=!ddW(df?V#9eBJbytN!&(x2qRxL=;?l48Nl?0j&ANjPE8CeYLo# z^fJ~8D|^w~4mZx!1F`YzX(LWmvENmURtEuh!vZf(J5f(&BHvz5Ylcd;pLLnGhg7;I@*`7{Tg#1Q^ms{BH06IPf>;)*vfT4}Yvo=DDl^hc_(pT4 znJ|4R^#?J=)Zv8?jndDp7$dTqQmSqrml}`J02=&a^_5Hf4F#|UuHOH`Z^DnkkHV{f zt?s3qDL0`@4$ke$Q!!z@nA0{N)p5X?`!4i6uELjKg6Yd}T7j&oVhA?p`78WCXE%%6 zHRJ-VnzFmdYKwWM)}hbwSkA*V(=wl=OM6WWQQHhnhe1C9&%XeFRHwc8`rBNhrqrUV zDp{?CC}wiem_I~6P4d_GHu_LuZd2%jOH1XdU2mu(b3=~Gm>t@pXuIs?Yge~eFGvfY ziij!!h_WM?D1{KWf#U5|u%7iO4%Fi_jlES=ctEdM9oQY(k9;f;2=DeYC@-KzW@lP+ZJ zhA5q721+n_jc%BGwRHzMaaG2A9u)*tw3(t__+2DrzPW$9n7tZEn`U(FZq_Tu@KCq( z#0=)lTUK^?UgNPu+{#08GyDHI;wxBqoOS*1nv-_cAAcZzetE0R-p@5n7uoB>!i@#Zfr882 z${^2{j>nw9DRClCg#k317CRUJ6XR>DT{h)lZ8>ccBX@8e2+*lIA$g*HYDb4zps-fisd0;`kbAE5M((6q}vCHFDRM)H# zE|{y!`{6b*l8GZLjPB4a&PUTWtkLmwqE=FEwPZUjv;qC#KRZTeAH4h{WjoteOGm=V!%wv)*;xr+T{ z(()CP3q_UZvamVG*i^cnEh+P1C|VZ=d)%F4jfAbFzZAbq(O zUNAUZx8j?+7$X&5&`V)IDGORjDhuyjSVfU+4s~Ud9qNe}GLB?2k!-C)o}&{SG>`DQqlaVyxq2+os=*);;{0b4irpYvPgF5Rdi+(h00Akc*U-er_YZtVkTN*a z0s#m)Kj(g$cBaAyY^9{m4SQ!Mx;zcBi*8#n6ttinnkex5A!^MyWjxB*vI*4AS*YF6mM zn@!fyIr+_wk>2Vw@GL0|L~Kb~ep2DJyxo#q0jZ)WR;u7Mr8L%ij#|u_S9C_duMbLm zL)4rgGBuOiVG7kt`zIc@cdvC2!=d4U-5XxChD?Hy@UfneIiSI>RdOPS_e>iCykkp!A-PbkM2vnGZwEaBY7+KpzS}0iK4Z;meQ2GgSe7 z_RQwxjBkdYWurrRXrB+?RbBnv0>AL-7kOwe-rTr}m<$blWL#wOZgbRqc_KE=S8=S$ zW2;{ByIOi*%XX8&(R_7nL*OY@d8b~oVOcjVKVKe%j8_Zbd7*{Ufni2r zabKHQ@5{@(tA??7%`mZv>Hy3u52!b5y^8_FpXS+Q@TaH9}mm767eM z_v0d>Xrh?fXI$*{&fZ;eo~JZ(JF((_b34KhQkT14;2A;-FZD{4Wn~4CFN+b^(*FB( ztC%2H048KATJlI?R36r9CI6I(h&jF}!m@&iqkKg?26?kCN~@}ERiYYCLOBa65wpak zbh>h86mbXdnQ?+SkOAosIdpleh}5emaCq3k`Oe@|s$c*g^9d0Bk)SaF7l0=%Fb%n8{<7mv(>jOuM`9`2gh%=_V#I`iu zGb722q?oR37-xGsJ2H0K%c2}glyvMKz?U?pH=j$N0TBV;PW%x(Oku2SXhBHI6&ZvG zq$VN;CTa~Y6-=m$&m|tfO%Jzg`vOUMWSG|09!V_IK5=tf9otfvTF0}kotJH>>LKNp zSxS=3`@SUCNmBK8V7IGS7oj9Z9jzCF8(Q-Eb#_i3n00Hb4=LvqV+zjtLpBB%$xsG6 zMF>>P>)|@M9{zcR*OUzo-+6daQ*X8(H_jT*JU&SQfirHHJCVJQW^-XH5KmlOyW+{; z7UBvC$4K%iNomZoTaLTSjt+7A5Hc)MRo@5M^4TR(5on+NXX*teHiDEo|MbT$4GVJ< z(7zvDdH1#NxxoKph#RI#MFKm!7s&YtS3k`>Q$6K&MAYegxIr~w72$1tlcGPbs`!o} zDIZR)7$FKfyx*k;#hOxOMe2C+c0i9(z)wlv@@1m9DzS2BLrkivI%sl?0|haebkPK? zgyLn96bWev;(H|#?D%Vii<6Jnx~1{MJqLE492`E+TMwEbQa6+V3MiU3mF3tyNit2_ z+B^NS>pgye5XbSn0X7oRs>O`f&CL)pf=|-KuNUPneTCY>D)oc6pY4m&chE4Ng6~HE z4b>#NqI}dc>c4w^bxEc6ED9&5U$o^?5L99Mi;rG-_zch{;)QFx9|zTt20fH{O91bmqc zEX6F)Z=w5Ju`|#pyq_fQ9s%;ZM^T}=c>eHWL%rN$?$YO-kp<-9=+F%(vu2LD4EzB>Mc_~G+bZs;*4XbRX=p@7B zp$sxN!rRWpjQu5w8ZCWzMyVcv$rVIh-ynVIGBm81$F)2^?Z8@sETeQ>9CRGwuexog zgF%)Q1>_U~k~4_tCXUP2HnX|ss-zjO=QmY|E*lsIW3!eV8QTTkQVSME zJ(*xQf)di6i-GG=5mIO+6a|sJi>O%N7KTiVg>x;Cme077h4Zlpz9_?`I{%iS9d-14eSz ziP?NWji$jo?*VJ>1P?@%6eNO)V7liMW#TLKP-;&Nsn=ilVaiXT(wNUl5ulHy=JYjn zpYeIJSx3e@J7)Ye5c(_2UoO$HHol-4yTav#0LP1W>)h!?S zVs3f)%wXwh=aPAz0oz;YRZOE&7Q0)Cm8X+H!PC80AC6bzU-&DtxY}s1R246kuEalH zdWRi$sVh63MX=ASKTopnNXdkz=~zxPHI>C=V<2o+`+eGF&|o$|L14j&SJ&6@QAL6TE*a>_cqW zT8h4_^uB2AK~vyUw)Ir?fk{ij`(OoDgm^yx>qXLJctZJ;IXQU|Cx3&lIeqia4agZ6eTz7;_HRR^=L zL9XYX-E`c!?|yF&YOnpnN?c$cj6w-U95iK6N%&Kb+-^adikXR>$BgcnJIZL>j=L-h z8`HIVMiFzRakNvz8!5HkD;*FDJVHT{mWe1`N~c5=oCmLxh%$h<7;8jMiJ*n%?Z zVP&?lB#|zJu4?p%i;cB+1_+)-H7kq0H-9!Lxo;7_a=-9w#=07Z7zvJAJ8Y&%!mqa+FL1l- z4yR3F2+CjsL{3H9>_(bzGZQs=%`nx7d^_h@B2OCTIn`59(6D-JO1Ldxqs zwne<@JS-#*=dsP>_W2~=v*%rQ94~ZiqkgKO?#PlmgXJS5vxROuRuDfOYjq2hUF$91 zwP0FIIbb&^tzt4i(yt#aMJDQuI#4FL?b2Qsz|&Y;Cg94Hi-cWev0aSub&?PYS{Vc> zKZRj2{bFu)KGRe)^fd@utaVOt)yw6XKU~Q^Rc$#$g5s^0d(3>1p zCS!1+3i%6#J(&Qn2>!Ra25g9yf9i`WaT@))@qq2=FX#9l*KW*u?}QEgR%gtcL5((S zW$W3fwys#zDBhJuKkaE_uZL=>ifoe(R|J_Vfo{6bVrC;;XbemuMjJN}2t_H!zRNTI zJWaj|8OoG{#7>EKA@4N(q1sDjM?oCB>bRM%#HSew9$n4fIFwot^H_7b$Eg@13!dH) zU13%e<>QKS&c%wzQrOuoM^%)FN$g6rRb6~hIJ3nBp;ipRW%&?%F1!z(%+mOp+-1AM z(=g5iEWc!yVwOW8>+zXXOfWrC@XM!|^NJjwriM%6?z@%-x?iT%|FWLbZ(Jg@L4vcHPV(@S8|LfzOc5U=Z$II(Rg-qD*{jRVDsq=B7 zxyPK#DJGt?h4q0Ww&g{ zVm@?6uR`Bo%x)V|rQTLM#)P%x*evkbNbJp(yph5|!OMT3ftF6MR&4|4paz>D!k9-G z8O|NiU+orh$O%aLkHMCNOPQ%KZHE#0NFTs_^6p!W(XIwhtr|G0Q9IwzA6}CW-y}Ad zPhq?o2Qsg=K!KySY@n zBh8X!?w?RxTwP{2&R5h+$DeGEOT(zzv8}zVCq>cVMYyaY3PCu> z%-dd;kJe1L&Ak|L9%}(8clVTiB2!EBb;ZTn$@_n@u}uw#PQXq+-wI~3*`(dj(HOZ} zXMQ~O*pLaL2KLN-_wrja<(sp$$Y;R6r^MW;j1zrTEkL2R+~-j?^S}=n_G&~Bb=PVE zMt5rGEGa-B3_);!2qESQ^sGafIZb*i2*VLbxM+kh46E0)IDBCUb+8!fAe%2i6)vLa z?g(=-lBSN7jwzaWBJI#jX>wwYa?;UH@klRt{Fv<7dqhSSfskl$g!0;CI!=?KkhCmp zo@0$1joJC4?5)vF?@YKg$CbUt-X643ElTI(fh?s!dM|&D*3D|%6}Qp18Xz7 zN&(Gp@|<48Iib<8g!rk|bbJYrg>2bYsUC$k8)FrWq?#u>wMqyk)c~(XB!wqj^jZBd6^`ChFZ5nnUdP_7;@2kCG{Ukpr4DQy z*7X%)IlgfzGQZi~&iYKuG{aWWHOVf6^48DyOI%369ix zVi;Vp3U@oA@}e!A^7zImHie1HuWO|;TrjAO0wJ7UtH8T-bRz@R13d8=&FmG8z*QIYD zab*g%%&KZ|>!PZ>(#0-FXiy{sA|1`Wb{@Xo8D_(iv3poBS(*QC)2XvhT0yK?S$}j# zyNZP$b7q8Std#z}^aEz+6#sdz!>-G+LXCR=}Ez{HT z{tl_{{9`7L7cUY-~T?OgeL{EQYu$C zQEvh<+hMD@u>IMUz~DGWDqZ`*E8!lg=A$t0jhE(ibC8VIgiF)Wz>(ix zhQ04_9U9Wz4lwAa^BQ_+I;N!X&4rn;!PAO)9S;~)u^o-Q#thXJb3??)s}-?iY;t`B zYnB-@*oU{{>5b%rAExzCP<4Axb3Ot93q1gnk{WQbf8wP65j=YuVEBlue#-BhT3%`6 zyiMKn;pG)|{=#wL=_NSQc|4zew!P)tq1+>XIZNkRQzp2sYz71nArA2g$Z_n|!h+m; zPSfOEc2QC0qx3(6-$Fj$HY~`ZM9y^{*<4G_BF}+vMjL8Lg2^P^pWWC&Ixn36;f9zf zsg>NfF0w-RKII29r2+)fcJ2twVYt=4e=2|3`~mp$F_dij#k}`I^pI*vMP#OhE;X?; z9Jhc;OY8?ndNKY_KJ0H6r+@bQF5dnR13y>?@$y-J!khet>K(F(yy9*y7WHG--@1u9Sy)lO*oee3MM&b~6V@E8Zv{|n%Y zfSy@JJ)+`Xozv&<_r4AHO+Vat^~S$)>Hl!s-fe&11L#$2YbPyhPag#>4H|H;AqvX`!nSb7Y% zN3ew~Xe(>N-7Gm^p|&uH6?eUeavF>}E(RE)sAXvVkVG;Bst^RF>Fn*VF8Olys;NQIsj*|9-uRb%xqs2${`dBWA8(N?=Tj$99k>4UQL4){ zBT8lv>^Q5(%jwYtG&l#e-~!kFuAaeH#a{ANx6oW%2Kd>0^yc{q&Z(MsnZR5EH97Zv z|E>K~05~jOKEV^L_gn2MzC#;Bopqac`#uI4=_C+>P|lxVn-TpL}nCFGeLOwN-2f21DFkJWBaPVS`651!-AgKRx;X#WB1z}^F}#lTXeSOtQr z+xNO>OqnXM07VcR8ptW+yUX!_ro3O-E69m@$rrtUX4E;SFUMHk=d2`uEd8Mb{plmr zQOYy0PJSK@xEA=SLgvfHT#K|F6jP#lL%=#36j=oegrNe$V9AJX*p)k2EOd2a5hE2I z3A3GCX^3L@$b#8h`3y)nmllGbNs28Pb>M{Ke-qGM2Toa@i19>@9Squ-lC_IV zwJOk6b-a@+;htW?0c5zS+%C6)@#Y|1kqS_O62M5+8UU5ang0@rny>uc_>xLaF$Q+k zLKgxxv`%=&B^O2?w3Z))@gT3#nk`V2kLz z({?DZ#KAWwloiL0O`~iqLQyZSh?Mj8Ln6X@jgJsEs<({dXfTy3)n&04D`~AAUrfVR z&sz)TOQLHeQ~kkovibpPD62ZnKVV~j!}k4Z-2$B_fEe|CFpWVaGwcSU`aWE1v;`vq zbCoIu*VVUt(KEcbWM(#%Qcvq(RbF&xjPYlr@K`gpuv(rL%st6drmrN+}AO=go7BF?K54mlSQf=pVerxu71|U5 z-i0_~B1JQfD#35mf|H#%$vDRyYZyKho>I#j(`;zjMUqmj(iX8;#B%9|kH3>b~aH|Mg<}F_KgomeDmv3a3{FQgePKr~dd~#NTl<1n!0TM~m-V?^`g5-(4-5 z_s^)P#u&`@8XT{QLR&k?&Gp^2xhcYo%I@DfQccdfnw!P0m}ZDv3>^KrA0v zlc7>50MkQU=~;6-Z5RVq=*S1}t-8wD{<*^N)5a ze-d)4;u7^wZX2{&z4k=X;zsT{%4~V*>&lY#!q)^CFGJ*uWAqi0Lkj2M9Gp|i&=raB zT81=`C%_EG z&P`YD5WrMm1Jr@B6{N3BLOl{w(c?-VftAS6N!~kCR4zvu7kbW6U>eE|WZi}8ay93W z?368rGEX}N5o0nVJAq=9k5(eXl##e0je&8y8xFBK5zGLFQlOiIHqA*jfL}1_2yiN# zF5Lm-Zu8I@5{iL|p>>yrFL4aWGBovI&^*fv0#C<>Zh9Y=&MIy+eRiEl$SwHC07T4` zPS>(IfGYl}XCNc)l{Fl`l+a72 z>R5t{Z^T?>zhh|RbWLcMm@iN()vKsBt3ZWfZd!(7+O|oSlT!fmg^LGmNN2(zi*imd z!&U*M1leT%cY@{)Tv_0rXWCo>y@YCE89+Xy5vF)~3eEy)r%jOr^4X zqI&8Q{)5~)ck=2s^2p3LPk)2P0D)}{qc7f7Ruon3SCasEzH{iOP+KRu<1$SJ#9z?G zUvEk?>sK`R-#`f*NWc*yooe|ugsJV{aKT(S5;)yGQJ^Vf{noVuu>nW%@LfM@4JFK? z*$5QG^u7PT`WePy0KI4j($p2u;N7oB?NdhQMtKxWAKrUai7i)Jm+S!Z;(?~pW@OBfTK?fc=GR+8Q{*AwL?)pP>8&LLX;|;gw zE9ynxb6nHZL~i}c`Cy9>7hY1RMx>fId0U)!ztr`E_pD!hVQia9>IY5m`_M1bkNZej zj=kJJWm-Z1Q%_-Ike(Tk+f54e;L7OS(;k`TxYX_{tD<;4)UQqB>@KNT}P8`fk{U3WX zv|s-`^K}V}W-5((SWSYK%SDR-XgrEpbD(B8p=v>IF>;C#Km`+kq+r(IXlNJG1=;O6 z0|rbWk6>HG%jBx_CtJDggF_G)pChDov5U0sN5sX?@z>?2W)MI%ckiaD*)?!>Djf`) znF1f`5IX?hXELJ3iov{=LO#-kbLxsZiIv+v&Q+rti5IFo88Lp-;01R^=$Oj^Hv3nT z$aVe8sO+s`j1xCUSK6y$*E_f;tU>~v4iThM3&b}vN`~-bh4rtGiF0HiJ4b!xI@hbg z&)ZUdeBZV2HOU|IBZsUT;ny{34tWKx4|+#XZVis1(!`f|bnp;1Hr{Xondi-mw{d+G zUT`~>>*6T6SVMrCpWQ7n@wAeOfeeu(TZk&16_CbgNs20IxsO)8L!X`tZ(}YLLV1*U zagAd;(2=ifBa&o^n1>7OWicyL4bWB;3C-M9`{mGeNyE?Jv?ktgv4YlFoG7Am`*rND zwUvad<0D2KBK#YK7Vt#qbhw9c>TUG*WkZIhO3C+PAN&BVH5Ye59;l6saG@VmIdWZ{ zlW_2@eZ=HNYo_cI7m^%K%Sx)aO(V`DwwUd*RWNcUdHKaGQg&) z5J*|rL=vP(k*C7cvU4$)8?;OPACZGOLpzH;yohQYyosPYI@4D!hq)%lRT%Xru4m}v_x z1~K9C8$P|q-Z3W( z$~fTSPG0?xfALpv!w?i{Z)(M2v7i>+<;u;{;pc5+ZStb!Q%+b5sdYLtyM})&Tsc6n zE+`=LBX1Ek{9KtAgK6gjX!UB3b#7v#)QkZuK~Yg)E7vGTZZ`H)Tn@Cmo2)siyr4JX zX^Wm60KE_45Iue(crgCjxZg?{Ub4Dgvy5U)O@Lr(oEWme(`VEk@7J%5V6;kOLJ!T@s=#V&*6BWt z*i1zJo${?y5;Dl5wMq*3$F0f9;SEFCDvcSnku+d8!PptNJpah(6Z`hs#}(5Aj%s2B zkHZ({#5vuDOB8)K`PtyOno*|-@*+63cP+VD^Fbpnp{Uw5hXH<|&A#8aM9X*Hj2Y)r zmNoHpxln*s;MoF_>(iw{zK8eezYW5 z<`0u?2^KG2N5DXyBC{lwM_~`i{8Qn?zhHz4r(USz0s-z^*5u2pC+wkezxkY@39dv` z(iNh_5j1>lS-(MPAC!hUv`$f)XPAmJQn}~z6u4b^)6#w^l~mLIl>AiwhTWT&rN6c4 zrR)C$h2+gk2d7k01>#|m`w7=Qt@9`XuT99#>;$Z-kV9ybh2Z?x8y;eOL%v+w0k?I% zqtpH@RoVgu40^!}=0o)efhi@r`ZXS{0oxrU;x(1ll3T{yukmQDNytiW&t1R9qqZjD zs6oM}qn^~|n|3a{)Xfb935-J?3ebCm^DU6uYtgks-Uz7?A$!4Lg$kq{i#`@UlfCG$ zLIur^X%Q||wx*oYWT%j#VD6CerR-3YF$yz1myI#mG5CUD#C|Ru1r6ZuyuEBu7Hd61&F75XE;-kv zhmoi0xoj!PE)@f{Se%mDT7-OhtA{zF5dLAc4P`Gfool2R!A#F7imMIZaM_f5GX2b6 z(`~quN;RP=uo8lcq60#;FR#%5@K{65e`ekf-q=ZwGF(}<0zfC(vczg3itn0ESJ#d@ zEOZD{!{90HM~WigTDy@VU|M}YZt4!FI|#@&-JLtqqr2ey!;>{Jp4eI?s*4)Bi#h%E;gP*3KEP@AwAj=a(8N_}q;&ovTZNzP`%vtfmA z(+ut?gwF|=X2Fak0Rju&imU718?TAlIt@In>Y8ELN*1_{!bEGMDg5D$;O8yhw=Rwt z7yL&af*Y@-!BeNEVs`Lf#UQ6YidB#6ngU05cOdTA3y!??yPI=zhDhFMw+*O9;#^|r zHxi-Rl(|g-<0(jqI|n-mq@~lul+kR|a=R{C5KVSv%O!*h*o+bz5#jM<`X7yRXT8?Q z{Ttd{2AkabhTVQ><1yzP;ivg@Ha6pvP3buQ=~rfAl7Fq7`xXp-sjoTqQEgOg9j{7t zt^dHM{Yk$jy}L|DvYq6~;2J;`0?|EXX*|>jd>*7-e6A$Py-|_(s?0E zPtKl;F+=;$w|XWF9Gxk@mza&tEyA3tSj%A5mx1TplT*iEU@-~kmLtgAQW+ z4uY{knI|Fj_@gpOYZ+PT4o3(;)N}K6^(49*_Sv}wY!HPgxWO@#c)Dwb@VYp2XkT?r zUomXkRaA2pIFvAywD~X+RJSO4^QEr4Kx$Uh5QtgIIs0L)(CyC8_CP|pX?}9tm@TZV zAljecG0KD&=J~;bJqi@s7O^P3Zl?f9+4{*SM$4vsj`li-^!_t8qui+5lq!bw$&F^qvl&eD@${W(R{{Xom%cwoHkq_Cg4N zP}oeo9x)M4>Fp>(310G?E^a&U-FxkM86cH3@r~qR6lV&$Rislit?zN;UccY#4X5HI1c@bs0!vw~2uvf5uLTAfwjV}; zZ7ik%PzZ*>-<_0$nes*i>$u{$8kr4>>y+YPbdnLB0xjMpS!C`TwY-^=_g<4H!pZ?E z>5h(*Fh%rl(;M%==J5?45vX3lAABzIUH{v=_dP3nu_+h;L{6Y{e@7iqlc^=K&}lor z(2*;X@KlVwh{NG)86BjomLy~eNIu59s9p=vbbg)=G3b(mgAOu%L9)i@j+?nLvT4)o z+^9nZ8IFxaU+UZKJ6f`#owwgqnrwxwA*Q~sqm3rOR`EL)jIuE=PxgSpx#3(ID07rU zJu)L|aAZr8i546H{o)J84Bsklin@!`i1i#{rhF7pV_0;{_0kFmVEcy6dUV;Suvx~^GRJyDdnv-do=Q|nZT8-+)gJIOQHXe<5N&0XA zWoltUuf}16x`b*b>!lBVAaIe~4Uv{&*A91gJMV4fa_GRH%<3qj-dX4=0fBN6SAIRl zf|F=zTda!~+!U`P>sV8l9IJ&NgcgdhUSA=Y!R)?Qbu z92*mTs93hrraGaeUE$HqGl@CFtLNfsQ1Qz0s~STnKJ&+4RuflA?UVU-@9gdN*B$B-dFX);Z!pg z_ENfcFaUuu3>1}=&O-PJFjjrBQmur*8GFs6Reg-Wg}6xDr=oa0SC;P`soGQ3Q?lNNIu+v7O%Ue+PPr-)|exTXDP_x&QwWc`t&Q z?_iku@<0@sJO_aDnQz|tzgyNfL-m zxE6fj1%;RU1Wx-&0*#+Vp2Pgt{z(iq2l~xsT4zLt7s8! z2YwsQjiW+hY=dq*SUYozrRDij$Gw6iMtafUs>_Zn>3mU~9gwJ80eQIhi3r1x#ctSNTeqx@L#lEj z3JDWD(%K0LrGe+ZjR4J1bds;0 F#kDEhGt2k$Q;hbaV=obJnjK z^l>c5(ttQhvAJd@8F98)#R;sGU3k)T!G#cUPpqU&j8dK;q^(6};RfV+bGK{yE@DF$ zG!?mKxG`?GgB6IlZX)ivPX&q9y?Y0o_2b^^aJ|B0D8w~qAU#>Svuxu`W^orDQ`*o3 z{c1LSk$7~Wnd^JC3vV)g>6|wzxE0}3>1EK1t0V)dKo6%H;7Mum=3MUuF{`jf-5GifTMP*^^P<_LWN)n+M)$}#Q z;zN6j~-}l+dky2kQpf@mq|1yE5|!|_QzEzy22zbcs%y9Jnz_|%eI0s%rcm6 zCX12m1yLN?PEmL>go^+WLuq5l+<^MdLd9U@O(l2$Kkb7`wKDzmG%6j=m!`AnSic%} zdv0|m{mCfHx_R+r#?o6+agUjzB1*~_RW3iPRz?2PwMiL#PAHX9d6p8l zj85<&DoM})`1^vao%0Rgz+$G=B$ICc#PMY!N8O1$OOw?>9$BH|*oLYo9A;{y8K&+A zepHMHd5Y(bB8bff4y4K*)LMzhk(8L71|4|zD`i%vGnVDQzP@iEbE%vPkEVvz>8iILjS2HJ;A8NXSkS88h5@GA zxaX0EQe@;a1c9SNx$7J`-|%uk^+G@!t_rj*W^l!0W7`#{&2J!?#ha`)+cqHc2+y(A z-1Y=FJ#lxr=es74Iz}!hT$xwl!`bwgG`}b?aTy)Co*(5L+r?J|c%v26X&Yi2_Z3na z!*+q1q;S5P}9kqr{2mKkly#$PS!$Srr2}jUEHD21j3ZVxWBH4HOP1r;4p>lWy4J0Dz$$katBn;CX>i+SOe8hu0zuft(~%k4U5; zX6rF{XIf6vr~yA4a(Ur8AEe{8!EoK-4+R1w*_q4veT3CA9!^FRqID;EPyV-uY%^h{ zYEVbm)2pbJ(okFlb2}oga+4cn7!v97PKORt?i*G%h8PQH6$^y*nBs8O8q2xPo{d9n z2jMlsL^0xJ&GzF?HtZ#Vqasz{@!&-3N-(DTmS)3HYJ91DY;MXJ#?igkZFf*l=MrJj z^R;Eae+d~byJeDxmwxJRvYG^M{_+U_>WNDbmJ(snk0$pr*|_K}zqfQfV~c+8YVS$a z=N=2+*dIf{m_N{~BUoGsFhK##{tOL8hqK3XCGl9URKAnTSBojXp(0&-kHdS+%cW0u z={g`&|D*QpfYT4mY0JO#Q#&Tkg|IGTxMQuR&(&qTjo{`v&9zPL{mz~F-#e#&yHfdw zUEKj7KrIh_;d`9t#!aSQ`;(F)jQt6MdAQGugW{{hnIA(pIQA!q=a~LKbh)Kg-8q!E zwDh+z|DVFOto(!J0z>>8p*6nVk&JmUweWc$6Z{P?Ne zkXu{H$1m=JTiVMIthV*-XaNE2mgT;}Z{6}gTH)VVf;#{}^5@B?003-{EjSlWTyi6S zBMpEA0{{g74(QYmS`hwsL;y~j{3j%TAn!xMO)Tv5A!o1glZa!lxbIs3LC*~L3l0&g ze(2AaI|=+i&uyl-L$THs*y(0Dm&#BQpj!k241}SeCk?X~1^7K)EfgCbzBsn;UWq%X zxw3?h1u{PHt@HSU^1!eIXLjP;5TO1txX-6}jv%wOFd6T8Ak-(^lkHNl{o^~%^yK8? zI~-6JpXJNOxq+5>c>txfeGYxfEQ`rM~ai1hYJ{@lk&wSZIhc7qsVCXiq}ESqoAJ%&ZDM={ z5hK_LatM8qqw)K1_X&&zU=QJNF z)k1#iOeT(Dv23%)I!ZK|5b-$>o&=S-vu_9gOrYmZ{a8?>m4+cPeJH?!5^#b}Mr8vf zT}DzU%kRGD;aNXgV4sU8uA*T|qZ$UXBuX3+MF}J9^7n;V4fsK3w>XGX&GCUb>=de? zR{u%mOFSI0l5v&;-7_j#K~_xuIU>3m+rvq^pNs=BIr*kY z9Zi-u(*1PIp|fPoq!$l;uoUKRH;>x6#f)E%I)P-40HOTc-Z+y{Ab5uE9XYBuQ8evJr!DbpjTTq995MRwC!-|*B4t#ff6$Rl z4VsU~M;Xynx-~!-NFfR`$epO^S04!cf!Kt?awN|bd5$R`m_xlf-N*nW11>9wkJS~7 z+W!??!^rj(l~;(h481}S9&Uw{vNJ1WeCk-C5YW&HC4HMKRE3Ugg zzNLretVWWI7bU4`QON(6T0vqlm8sAos|O00;Zo4#{Lggz*AL4GTx%ti>iOiqYJ4&J za=E=W!wAf-EnE&>FkamCRcfX7LB(pZ=SuBwP~U9W{!2rtInd|D*cmCNC_9j)GS<%{ zr=eEcYV~eZyvf(JJZs=pnXH~o^ zV-?t*2s6us83IKxDJFI47J;Dp^NYtlBN z-CdOSmrR~cxhR{{b6@woZ~)njJYDjj27_I+o(=X>&E4ALt$lTKySp0!M#tPRPME|_|`vnOkAt_!)N^(+=l2jNO70yRZ znrJy{l!F_>HFqxUR&;ox@3y~qId`YKevmuyWoL?6Uo5fek1h7#ap1%6obri(EkZ!X z6EA`I48$MOpdpvl8BQ>vgcBi{XkvsCC!9o*NhNKWNHRo|O)mKqxTHwTNJ>U2gJ^O! zRpZ@{<_fGvnrZdm|EWkvt|raI?T{cDHr@0{rEk^0&dx9+(ivxxX=Y^n%sdMd+_KCn z>uj?1E4%D-$T6p!bICQg-1EqjEV;b$&L>~;6l~?^ef|X$SP;d6trb#eVTBh_WKl&I zQ*3d?mr!C!C5J1e)Y3}tVHstXRdzY$mREiS6;@PnC6)H5$35w3&w5^2FDkF1%Brfa zrkBv%Ndq~BCo_4Obv)RNe0VE!AIASIm_#MWEOk>4B9#U|sxqz|KdM(nYQsZx_vYDlKZG&$VSrAcFEg;wV@BrMmqn9j7iwGEV#AxfNl1t(3K zGHu2wv*yIjTd-(JLQ+avCYtuvoQ)?ZA9+qq&n~6oZ)h7a{I9qFGuvvdt@f&_ zZhJkk(N&=^I09KU3XQ?y;!JO2_7aN6myz0YZ4ihga!GZ^Q>Zk0$qZ&i`P9-rRakQh z&uvkeWmHjVFQif)D|`9nRhG~jH50>fydX+4i4+wgOaiH>nr@hu?YN$=WLS!p7$xA@d=;wS8FPG$?fTo>J{V()o=cJu$_HFOSy92uSmE zXlyN0;8z?Ym08j}dPH`5ad5~NgqD9rA^E7n`)FYTKMN0skxkNwCvjuKue);_3(Vr( zS)q;nT6OEa@YpG`%v@$+z(GN`#uDkxvN?CI@2(PT=#>IUwwy1bKiTa6>v;F>@$zE# z@qx|zT)?V@?&_AYu#Wf~U-!CHvSIgq+1OA!4VyfrRmndG*p7FZ(FVaIM$@{e@sr)$ z_Gx2rWfk5S45VL6zcu1^F6}no+>%(k58D|nVBaNY|IcR6PY>@+RC8mmyxVx8$voewCbot8!7tN2lDHdVr2Bb z&&k-U!{tUyO@F43z9D0fq}HfV3g@{JYFz9NiNJNQ(DYO+B8S1ROT-6FQHLvpST$9f zRZ@3=(KT%DE)pz%b$wqiO92?2j-}um{c8#)y;Xrw(imJNs31rn3FuzUQvfP#bv!hn!x)=h;7L`I!0dj(|^FWE_u zCZ`wuMs}Wf8E{qv4Go8Y1lz*Tw5b6PiHJPA_8J=6XsfMp%^G7(az4vI4%+GAx#k(i z#dT(TAruCWyqYGlC1iR9?2&P-lP7g4;X>^p$7Ui$P zqFx#SdUgzc8uwk(zV*V zh%5*W9sv;v*_55SgiTgJLII&+U_rTKcgp6aha*to;k!IDX13DP@;TFCIj~oR6E-9i5E=#+1ZVwv z5{HC{9!h0_K%g2Ca9Z@|AjbQ6n(tnc;anckS#Q>w^EA+XPs#JRuZ!tCLvE+A!^5cL zz4o~lwf^k5xV~x+-%VYfy5H)Be{p(qz;}6_!Ak4bOf3*;Ytp1iZ$MeChe);b4}57Y zdot;5J)wJP`PH!Zk|D8w^rw8{K6mn)la{_VPiGGPa;UQ==ni)yNvMd<4`$5n6pwKo zsyFFtE@%v(K3zLMd|+|*4T$Uw>C?jvPU`J7Dw~5}I@o&=Da7h6a&j}IiDn#lr79H# zcesZZeUs9KU35P^u=Ntl>h{1ZQj&cR8-lBAK-KkPtQ&U_LR~SK*7w3^xV~TMton2J zhYt*rFK#_Q-n0f=mo=YdVuK|;2oT*-XW=_0Rd_l~>JERuUP0mPJ~dZl^^vJ%&8aWH zH9AHc3JEq3%>`M>{EF@!b$w28^?`^|D)Uk7xO&ia^?{gDS%9qDga#H=I^KY}Fe)8a fxE@$ju5^X#OYG{VlBvrh*R2x56^6#<9x?y`G1CHL diff --git a/backend/resources/fonts/Mona/MonaSansMono.woff2 b/backend/resources/fonts/Mona/MonaSansMono.woff2 deleted file mode 100644 index 2cf264bf2239c6923be78b469dc5c6e74bc7f2fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38400 zcmV)3K+C^(Pew8T0RR910G0p%5dZ)H0c{)r0F{;i0RR9100000000000000000000 z0000QgE$+{S{$!Z24Fu^R6$gMCMPZci)1f!3WAPof#GHgi~;~Kfyx>IHUcCAmtq7U z1&~$;w?SKCPmuvN$L=4|CE94|HU#MDR7-5Wo)We(u)OtVSmf^y(`}WJVdDS-?AvJB z|NsC0rzVpzv`dgn5`-e1y1MOOrNKNbz3m-)4!kn=hAAQr&CE0lSHjktUm{{DW0>lT zh7jg-INmOdZWe?Ir#lSq;h^Vf&JdS&Qk_Mt?aDdeOGMl+8Q&_Yk}9dPXepol*=>l} z?BRBaMB8N5>9$SQw9wC@pL!B~A;kE8;Us#&qqCUh7bb}tgo$P#4F>Mi{0X?cM3`nY zK}t?E9Az_?7ai4nea7tlz;!lcuK3-)QJ=;0ul%WI$|b3PSbRz{k%mi9WbysJ&O1cX zBH;*ACvj5*QlvZGMI(B;C)|x@i!c`bQ_22nhm89EtJeBJlUdypI#sfkbwE5ehbj>* z#yaCke}A5z+n;;iTNE2G1|vt07*T^BNR(9=AR_wt=*P(K_ann7L`39cfq@7(1mh!M zi$!{$}Nl{Qx8B;rY4swy_Ep^;tx*jSUu58yHPnr<`K+9Nx7RT;Y|KR~TnRAqKQn z(AyuLrt@C~TBn9JZF6_IyGSEq7ZGo)X`QxI@ZuAEDzLT4RGA4dVV^J+V8;G;_BT9j z^Y1{6=^`DgHvTGqcCojLRJYlPK_AuU1$oDDREPoa&p!w5{y&n9CZoY@=M+Q`VHE1B zMB80-W(^rNE^c6^dLdNIEgwmrT)GG=Rq02mvpBFIBZa zMS{h(4U-Q2fw7Qu%9hg{9c`<1+KD58=wz3>XaxJj#4)?rR1b*@!s$%{Jr2HCeUf%M z@ZI(_d!QfSU&u3k;LX1sd~Q~R$`D9BP%M0Q@1COOq2BNFe@Ip zz!hq488uy?SBzps&kAh|0eU@c(ER^DH9{XGo|oebXm$;*&3mcCs3Zk1a6NMj&Xvk` zL{F&7yxuT{OLK*k(q0c=7&$dzChTu$&9OSFcefHuYMGh8_~tKu14<@S5ENjnjaB*W zFqtgkz??f?0;O0;sx~pbh_qK|kO~zmFM}mIg-T7~5<8gXaCR^sqDY8bK`Z`{l@qZ_ zSSYU&KV5}f$#qoBltV~WOqJco(#K8dqHX2G@aS`MNK!qhJo|oi> zr8Lwnsk92bTR|5H0;Itea4AH}@sm2^O1`a7xx)dzuScTMj-1IKHl-u52 z>)xgBot956jV8X*oqa|rozRhwINc29q;ckobemcLDV?tmjW_wN0`%eM;lKNo|8ldh zA_1Hkp#Nf&6YEB$mAD=&W04656j=j;5DtEm{a(3?G~M2sS!QW`(TuO%hV}S63Q4bb zh9v}%ATkUhBEwX~ij{Op&;O6H*~4_-!8;?*BFaY1nPJSR-MybZgDAoXBa8_m7$dSL zSXfhO9W0K6e9gPpP?bkg$F#CKb=0LgN-4n~{3RHF2vOBBvDgNduLi*y$N8T*ZC-&~ z&z?342+aZqjV&K;`tj~50YPBk^FNaH07hZVU}O*xRza+h1Ci@36H_#e_oLBxj-&%0_55@fy^!&q_QZ4zlA|SU;x+vn8RR|sR8_jEKtA@DzgIkhf#2SSqNhY05%8!0R#eM zWMBpa(hUG?*>|*6w?7zHM_4vQ%(bpcJ%_5h)l0l;OYN%uv<9h*_8@rB?@K>L==#8d zLBF~a9WWrUz|nOh?->d}yZxeGoknY7K^U&?yeXKFMo;&SxKzp5NbAHS;U<`;vDgs8 z<vCsEd-m{UQOsTP8vPEDIS9gCmbjoC128=dV}-24VsGV(5!|p28LFy}1r=dcnuk z_-Y(KQ#bMRIO=x4cA)FTEj|DZ;#X4wU-wMC`ZzZLtlu;yy2r_&!+`1dYr=w!DQAJ= zCCRYPb|oq`Xu$vgK?gws_)zp|^q zAP2ueg$L&Ef?y+taBB`2ve?2BBMyuPkxGmQjsuu`3$O%0E`Y*weX||Aqe#%+b!V@; zgw|ca));ZVPN6BVw%HnccSQWEV-OPn(@*%JbJ_?7ohb3FW|KzJN_}8p9DqK45hNbR_=6d^D_&Z0S1RIx0Kl_3ylsvFK>q|qX*BSN z$2!Z9jeOx1T%yuxNTB5nqk{n7qa6WgOY#Jt`vA~YO12Tuv)(Ftx7Rg^8#0sCJ|qD6 z9LXF=%Mi=a(%HjRo5&}>i34^Pl712z_%2+14i&8Sr36ERp-T(j`o2ViTuTuy#0zNqQ}BLK7rjOS-;65Q_AZ=?hI%?2^%lvGy4;8Ag1StUMrt z$S6!7P9)I2lr|Zk#G?jc9dWG8B-Ad4#P?i?>v?VscBSpd1u-3+z7PCe}^xn;_Fy-rY1)6JC zJkv;1j7YFGmB?IH7h*?Z?Q_@yFg;{qq&Q6VD^d|NC0? z@a(FXW;1t1+o416DR)dofaqi)OlCCDY2Mg~wL;{{O^6Zy|Gx_!u4H=gT?LWr=JS81 zKJ()4^@cC%F~bH1C+{SYIwo|fC)|SLf4QfAPq>d6cbb;o5j4fk_e$*D}Vq z5=rV}^l%k!7U|-_5oyTHEXxMok^lSDrc$9VC!Aqhx-Ovgy?on@*bl5~2T-lURpxyr z{eQJKRj0quEfqcGHm^9G-wBt`P%=`{S zpYFA@HF>hLDLng$c%|f?9FMDE)ef0r_cuG<(yz&@)f{Tau0(HZnikK-yWT5lAfHov zPjO766H9$|k$we^Q*V}7;w0apK2z-fb7E~AKz2GVPG`KS z9Nf)k$)$Mo_!<&t!o)YaNv$L8Meo)j4O%}`+DZU_bTVLD_WPdrZ|p&J+B;0UYHyYn zYzOc=BYSd^&Uv-c`>t{&+Xw#BHa9#HqK4w3HQfXeKLB3_9J^}h-C#5D8J;gRXy<#7 zvQxn4=3Y2J#bI`x#IGm$d|Z1ct2k;oAn$c!mFU(x%ZH zp<#&hGfXZ+-CK!&wc58$uXKF3qk~>&Mz18+J0{9TCRxP+C1V0YB9Cm3jsehD;nreX zf>iztO0YJ8D5Q~TOZs@(L{3WZ;-kY`m5;I{fA?=c&gAU)^`gP?3$I7!Q9Rk>fPk3z z4KdM2OYZQE&&-u4(h(ovH6L`b5Y;0OgyM~m=Qw6VTw5O7c`VJbSRtqaB3|t7^xVO2 zpkg@=BF_*xtzo8qeGcdUw=9NF#1=q^FeH;qM z(_^*4Tf+7#YK{2^3U&Ip>dVdBx9Brv3=sU=NV(bYLb3%{^?LUqB5Mf!;K4Qp7=wyu zk`N&g5x-iLlN?(u{RXaVmQ%JXnT^%u3rXY1E18tfH0T9h&cw50v16*z>$a-z0HOxR zak>H`uF$;=tLeohgv&DqV8@q$jEq=>L{3#hKHsW%+Z>RfMxD9N z`CqDw8l}7BhCis>m*t7KKH^stC`hQ1;}x{0t~95c8`@^xr`R6%(%V6c8SJ=ajC5Ni zZ_;UycL77Ctunx=3%=rv21R~rqaQ+J9fx-oS{4hwMyni2{Me<9KEzgJCtW(>D95?Q?0b}jB0PWNgrfq^D z-sm&Y%KPrd%t~~>c(;>cWAh*s?|q{LPiqpj7wg8YG{~hNl~!MFk#!`i>XpFiOb2;u zR-@Pb2#9LTYxv|An%v;g@yz4Bj)D+=Z_`Zg7R&gn`>bVkmm@iC>eyV9k4HJE>-=~= z7NKd*^xd!}(bpS~rgrrHA_!|dQ2FUt{pw|^U0ZojITi+%{=8PBL)WQkeT|k5N$Tyf zGh?N3HILiJ5;;>NcaFOn!Q-b`cf2p5N-Uh~Y%GB$60fz>9}b#8=KMiCh|DCLRN&<& zk9uqW>b!S>=y3#)ix*3AULZ~JX4BODhw6Pn3&(J@Qy2ShxPK?)JsJZS^{fs#D8Nx$ zx7nY&ZX%YU)p_K@Ftj)|`l3GQ1=&{@%bV{mgPeBgS-0fk^RidPh3*m~;h7RSp@#0WLKOL&b;dhdHPh zfQJmR(SPu2O9BxUlvDY;^@D$q8`y^OdI=}?tQTSivaT)Yg4u3eV41UcOJ$C7m9cH?pnPvn!h7YgOxD3<%6N!%CB=6)!> zUy8Ec($j-bF8F_%?a3DBx^ySdP=OkH=`WiV5o74P3O~v#T->{BjONFM_R`2ZnOIqn(r>`|Of4FkA7`Ug~4l%9& z>Peck7qk&*5&&W-N;Zh3BLO5Q9|IQq?g$h+SEWa#sM4g^@fLSXCSk^nFf*>MTW?Ol zd|JUa=b3p+6mQR$zwf1|M3S3a8X;AuFNr}66Y~@;U$Aii)Nnib7nv6BdLK$-<-@}<**#FU5wR|t}6@w~4C%%ll|BPkOcE)qWHB_NWa7ScS5@N96P zXU(y-Ip)rrZA-JvLM0^2wW5Lz1kR-Tvmd}C3@u?1fIH!2Fu@pPHHaA-)gh;eSVEdS z9V}+*ZE?LI>(who?RSt>KIPuop?7{ACyqDUFxt0VFg)jxMlR0|crn6EfnaBlwgw2} zmCaF@C)7U7U9S!4HyHXCCnqmI%imMdlBv+RILyx!;WHJ}pY$vR3l%O^x=j6*8fdVg zhD$)Bz@I-Uf7U$tiWM(WU)gfyD^%>`@Q8r_54e;YZJFfRNnm60s{_yXW z!-PSMM}-ZKJ}b`LxbqM!O0*c$%(u`Yi|vwUuTtesQs^;c)YnjmhUkcajN~a)q*#>} zlMHImV#xtVUGKa(Zgaa=?a``Tbc_5O%~re9?e*)Zvw>zh>9n)XyXdk{6|8S#@>8|M zMx|McJ6fr=x$B;G9qz;6%VL5&BK-CqXesxk6`r!vD%;)Q-*UC5RaoO0YwZXTUO~+P zdhU32sI}YQ35VCpML@rGm53_UYSe0eaGtESPgU0DSy1u)$lRfnuMaPGT?dhMba5Zo>_70WK{9o-R^R z78jLnaz$-WVlF5y^PwWo*YUafq$yQY8+`BA0BEAhH3+!{>XznW?62U~H+sop^8Iw}A=dY9E1k0ymz@{4W@n}229fq)`uqFW%9oQ0K zl#vNSQDZXx*oj&QG9pumlS(KAC0&;JUq1v2?5x9_xVjWjEqn_MRm-T=Q>eOnfETc7 zU_((lCyN7utX4g&lE5fpFoU)Mivy#^fy!AlJ>C!p)RUTW;3c$N6O(iVzbI2#R9R7| z;9P*}ji&gFq_*70L1}cR=G&-YS|wA2e<`lXKUZY3u6#`bvEaNkVihBoC#YsAkSd}I zqhN7GflyQd!&R^#s^|!2V0zGk$o*HfuVI_I9pa!34u zcUyNM2HR=xtj$hv#RD$!p4pIf%8tf`hbDR8YWPYJ9`K`TNKItY$MOiqmXYD7))3Zd zh`>gZcOZv~Adf3Zc16E1xfK*|R`Hf7cI$C->~K+X@)AqIzP%My(W0(iO@}v@<23p$ zZ=M8xeln(h;||5OdgDUH%2jpsm^~*fLJ%XM3h2_i7><4>#zk+q7|=tHQXAVqoQ3(> zX<=Ksb#5WNZ%nc$ttD+%d%hwVW>H{y1Lxe{Nh?edLkq^`&WQo(U}ZJ=6@L+k;wdEK zoZObnN|00>^;O zb&u|9cvFMuf)Vt%ahmEG>BU^vW~~1U^|(8p^Su9C??oGI^pcmo;#I{;lqyrMV(eE# zWqtUMdzPi=n0w2c*X6(%V*->6n3;Q-N5H_7-UQlv6dDoe!cvS5?$+ZZkY7 zeC|^I0t5;o6Jd-qMZm!^65jM>;SHKYXQV5>Bcl2{Lm(n3nXTx^{VQYz*pO%Evz z>nFYwvDHHQkW`@wWoc@Fy4?HGMGdADhU|jx!ByCPD(sQ&|w#M6L zqoy3lBl(xrwzj3M?r5e@UsQhVG_gicX47yYJ+n%KMopS^`BrP~*Q-xTTEC0|S%a;e zK*a*%!mjWrAYF+H1;F<=1Av76#?d)Mw~Eh~ECX*I20z?%%=73&xk~h3OvFDvav%>? z`vpA#0N@eU03bqwTG8_Z@G$_eJKL?l%Wet)_?JIVmS#X0j9QP@PlR!jq+1370H99= z%;Qmz>r*i}`A-KlppWavc*goEZd$)AA>zvuxknJ7&Yl3cEIQ^GA7%vH1!Ifr^5-Y( zW3Hv}{_wtZj!a8(Llvt;zx$pBOiuy$LB!6<(Y-WZ_GRS1YIgxPsCF)?89LqMLCZX& zww`OIRHe^~B-n&OnU!sc%fFiH>WX0-ZW8`1>?^pq1L}l&AOyl8E+mJH&^R;)T?L(l zDxqqq0lESk!Yg3}X2U7?I)s8y5jxU|^dP;61<4=yqu>E` zFkoXI0+piRLIM(>2`NZvYBH>eQg7Q;Kv0ue>rlSPe$F~ELu4W{rvSb}l#LzqF9}x9 zvd*tuWp$ce3O)xnR=HUsb$s^yfhRBig2@-G|Ln&Ob)Y}~=R_r+0swyU3xJ;<4FG<+ zaSOX;WsA4@X5at0`BQU$L;ua1-|yR`H+Mtx)TZMAe*CtkUFifbZQ9bbvT5Vweo24u z%wy8iKRo@O?aCH3&U-i)zia#m1o;53k53Xa!z9Hiiu-8%F^Eu^fb3VWK zDz8dlrVfH!!!=)Q$rB1M@blh+LtJg7hrMK;Yg+ZKjj_T2lk_+0TB%V}y)_)Y3k<@+ z^}EH6AE^*BV@)v03^OHLWsSA|k;^s?a)_fGqsj$Fo9+eY_?|}I^N~Ix;UZoH4Mn@C z@owJDiJ5$9&PX!;TWfXJ8V@I-PX( zgS!p)hvM|OLSqG*D8m5ZuE4|?kAMjap6rRai4`GI6kBurA;ml^ES#e(x7&6*?Q-1e zQoQukfVZxDH78GscbX)l|6MM=eAUZkCVQcfx z)}L%)TN|2lk}OOoinzc_M_KDR+*(Te8-1e;X|Wi@VVEAzaT&p82!(DmdeP~_pdXbU z9^t_&BKSrDr;y-M47i2{Nf>Yr1#uz71aU1(4Kfl^tK@4GYNgkiUv0SAl2(7U$!fQ_ zMcxDXj}$yn_{_fNieA|JR7FTMC?1x0ge$5pPtMLyBftIeYhCTSQ^X01L3SM2@6aFs zcrXY)I?R5x4HekJ?r{ttK;Pa7)F8b*hYA=#j}9$F0-(PiJwjA~e)^0PLU8cr|8r^ZB{!HSpjEfldhC;FO)(gQ0IB^Qi- z)h6KojvgGscq)F#yVHo5#RvpDu2CcjX&Gm1d$1$~T_@(pnL~d(J?R1I6Dy; znLBI8uX{M)DfgyJLFpXHUC7A{ifhal5n(+Rvz^?_@T68XZ23C2^gkQKT_ zS-|oipfTTiY}l)ET=2o-NF#^nFX^O!;2%K-FNZkop}T@uHxnGRv-M%ku}Ay3i*ym@ z*&-giD1#;u9eV~fb8AzzRk{hXA^75PXQ>xO1}3>~1PQhW?C}w6R3e-)EiAxi&-kO0 zlzi8eRFg5Lqrj3_XmBhvqXlKtKr4#}x@6JBUI`Yh`|kJ&GDfqPlx1nmeQ{M9mDPBz z2-xz;Ai@V;OT1c$%lCFDg0yM!GMFQ9EZ>?&y@A&)0T(8^d^1b8iCD&ml14G7V={xv z(I)$gdT6$~a(gZ$)lDj8x*9r_!M~_#znG~%^T`}@xz=?WQ2A_js%urJW0$dVGpl#k zWTJC2LNjerlmX;ynEh~!oAtawx+jFIDYkAR^!K~nyi}|5$8_?`W3&+0v4^F4eJ@Sw zO{Ns;X}F^PU=}+~8>zwNd}pvA_*epBcx4z0Mht4+>(;vFWDu}%lV=T+|B3Gf3$Y$g zL(!lbjjXj(lq9xF#M@5i{e)=ELpVHm7>g50p~H`L85p@gF7GQgm0l$mX-f- zKhv#&78+EX9Ny4dD3h6S92%^V!`XB39zrRa#;h3YHZnm<%R9ZW?y><2o)eJ7AZFCB zQWWQYd4o*KG?)#rhXY+4d*xD;tm}*}(>UIlmjUZ)&2B&V%aG^?aJeh;#ac~q#2tp6 z4bvR?rhiq26v}c=M_iK2?6w4U$utR>P=~Fkb3@5Vlr3MvT zea0vP4T@>DR}pMzQ2jpRkZCl)n%E`qV=>2iV5z9j&G;SviRSEV@v7SGL?(6biVDpj znVd*$@Nc??#EcX2m1=}o)3INJ3GGtzeP0iZEu6;^I>A_=^1R0|O`}y;oYxM@Eb2M7 zh`-9)OK8%hLV*f4cxzYZdVBJb18f}++YuFt(#a^q_7ac3cbiy;gTNEZS`Xe-S!-WV zv1Yr1)j}6`Z-#qv-aaIN@LbP;)P%&;u&K#{>mi9VJa zIN{LYl5oVyQ@TdV{oo81a-xL6)hBAU7Ps|zJ5bh6qF0n)cbj0@GGpw$+}nHuk+?|7|he|uoCX^jagJ_n^4CZEYv8Oa zX8D={p@l-gZr*15i&ks#q4O0|sQ`7M6~BBTN*lR#4fASU-i9~G$c0%tre>iXiaX(& z)1B?)-P2LHzg?xeR3Og4pbc^=B|bDrf6tn}$@qsA#WLZH@{Kg&1W|?94w!3{CYPmJ zq*=)ro_{*5Wgo|oCXIh{60@;9$^kx1bE}3j3O^%_yt&m&EX+lUFSlZ zsr1?5D7>@wKNM4_-H<9GAwk*Kn1}6vrGAQ%Cn6u+{Gsx%k=hcoZ^dY(+M-9W`bS*D zO(ny$l7dxCbc!~Ut$A3=Bwc8Un2@$wbH*N?yFkLMAN6X!p``9Jw=w zZac{rxMKmlBJJao{8!hWoFd#@d7mMpgP0mvwvQ017=KuUB<|gWiPDnd|2ubSNEd3k z?@vh#LcK!v5;c9_xcc+&KPB>?_S`4NuYce=`}zm3i)fVJ>*4gf+|LtKoZd^BZ)p7G z@khsKd&<`NFTws|P=yMyca#nk()=-xuT8eHw97N9pe*x1;* z*mY~d;9FZim0rV=q}jy$H~!)-U2sn^2pSTWQtMI2+poQ-Tx(2EIYAw%b*)uu)>a7S z)0TS<_TZ?m4P{<8su!Z}N+UkV;Rht@ys>K992!dsDkuGw)7Mt+qLoFWw2oOm=5k4Y zjj{nZOvU+@jZPS_-EQ$@PME|FT=-vtek5<`tNjABy~9?QXzN^}fIzC;c}TKTfq9;% zD&(?TXd6bSjEL&Rz4U>efYX~?gwbBg%fK!f$>BIv9?y_lZ!Us06Gx4IHxTcih`w_> zkAp4zEoAd1*@)RDX!nZ#}gK2ia z4?zzGYp}%ZZq1kbxVkxjMc~yrYwVrP?+Eb9LiNs#`0l#OQBAjtucMqwZhx>PZ76;4 z#Z@EnEtL~xOMJ55$2&w0I;@mtpVMfSnBg#|xc*R(dG0$Uy0>6S`@HG5l?qc=XB*me z>$V~uVZ=1haDjySnEVT`PwtwKQAB~bF7^=0_b;^S`7-X63Br&D2kR+$p`H2pR|Xso z(Ubusq$r+?U^=HLgdd2TNl`K^3&F>IkCM!Uf|yeN$4t^_ouH4lHrmEW;>!OoOC_#3 zA(oK|2SpJ!_Ol3eDt&yfYbjBp%8=3&{;{MiT<;`U|2HqQa&_gIp1$>nRI}NY+3xCj zcFkZ3mi+%IQfl1EtezYOCFl43FF8)HX%OIF7_GhCc-`!ENK7<&=hJhcY!%@${#M43 zM@~u==zW^6H~t`QqT8r*6&ey;~ROBAvs-hC1TDHeKswpg`s zxplrZ0v%H2p8L`!=O~-dK|0lWUcNg!XTYa|kY&Vq-rrtY^(qNg_jzAjbzDJW=a4$~ zrd2AA(<{m5uV>@qP>inhC}_7cuU-1C3u2a_=x9B~9<`?qEt+G?$>OZzh_d}nqNR4} zs&R(v>8$%6O~cbSpBd?Hd7Q^)9txAkZp{UJd$08c0(*VsEf>)9CnEm*s|r00kNThU zcBh2@va2fI`r@YyCz|}qJ{)OFow>47Vs||OxLva0GNU1}Dp|jJ+)$q0>;-+h3B2*w z8_|hY=C??fTYC&s@^4pqo?}e95MQ$*wq(WV^xg(0%D=cm`JjpuY^ohAOX(ARLxD?d zTU*p&F?!YKDV?%Vx4~laD_=eMD(F&kliK@7$8-zRaknNf(+$_z=naX%agQGGrK{-Y!GOyHc9QE~ zKQ=BpJQDYj4>zN2tS+QW_bsM(*h+5NW1<&}kKA&rz1l^PEC1eKJz3I=b2*XW1N2@b zl?{f{nef?nPEw(SFD>-PnsdWDOAKdnZ(*$yTc13airkpU_D-9wnV)^YoMg0=mRBqq z&Z==xbMNExMg6MHzYu?pklrn7*exv75q9x5G(% z_&61wsfBWWe+9v)Oec%!NWtZj;Cc_$)a+`B(Ku#ha7`g^O!2wN9$KY89`(B;ZX@67 zhV9FU<_J~dw?<}5!ysS2N6_?N?t7~e^E_@+mt8wPxC_}5k7qoAcswWRIo+Q0I29z9 z@hkYp2Q79j;I7J?=&!+1iEzT@^JhaS@babHJ@x$kN2;hV%RC@IP$m9f z_*`|@QU4M3l<0a_UwDrHtJan!;||hVtNGsPIw%4Dj!zxE+h5ZT*XxIOyheX&uew%p z;G%n5y=U947a1UNsW>jlKG*kPUG~BGb1dSxt38-@vohRQdrFSFHp?P{wLdD%f#foN z9It);YIOU7d}%?uT9wc)jE?FSW)f~~fv?Ne5sYQw{)~sn`-O%}a^+Vhb;3EH>T_4}4eQp$;)`6;-FpZ6fuuO_8PZaDg5t~x`1>QS7=hf zYbygZxUyn`vLI1d*Yq{5v20c|U%Uxrm^^-k&|+z~);FaDTrz;eHQw`iY-}BUlF6Xy zSe*^J>n82c_c{G`Vda8%>*ynv|SZA_m9Tr!0N8Mm@GuF+NF` z_4fQo8yWR>?7a8)jWgErYi3>iiIhq!4G6YXl7|j0s$!X`*_q1=W>4=b1mW~V|43#w zCntIG`juBPeaGak2ec1Uk^l#JZDYUeY5g6;5@v?;Cq0Y9vYN?_vC z^n4R27D|!u;8=AUv=6=rT}jJ7mL5imxc_lxehu}1heG3L2aWYwY=2PTP#L2mWY6Dp-Hsb5)NF%$V`n$j1*Eyx!hAtS^$@)#kvQ0z%f50p~V9xz@; zN3p3q&-C{FQ~yMrY(M__tJuOw*jQE~)L&tQn|b`(@qBZw)1)Fx>n0%m7Y z%J9i{EhENvVG)T_WCy>fC3@wI%cYzfHCW6edc~~UWor3UGd~)a@cT^z!<$m7q?BJW z_m6H&f!P|no;K@r(dO(1p41a>@jTSzaV2CzlaV}E?IZ!(tg3PClw4+(uLd6xHKSuB2ffTj**O|JTk)K zl5)BB-cWKXMC%@eY7Wy*{{m^J@eb^M^b|O+l7~t%j32Y4oC3F)l{YkE zXk>o9d>o+#8j`kk3%;NhFS~mS?OC-OFq3GKumCNZRlFF7Jex_~3 z!fvXUZQg=8RC?Uch(p0ds3#SO)ek@iVbfSRRlf;Z0G~wyaXJS1O*)$ebx)K(f*yiZ z^?!(0pU2@pdfKJYr>(x)M~y{~tlZUH-^+hj zK5&+9i9an+T%(OJxK1vY>x?j@(lCST;PJSQFhgn~2OLUez+tt1e@sj|{qnWQw`OjB zBlt41CcIv2QqB70)k>5F`zVge*Zd5|DMc)g#Zy=We1)AbC>(t7HVUR(u3*MQ1X6BS zAWfvS4!us}l!$dsy-w?pfQo7?&+hj+qM@||GAb`}wmO~C>SaF8$M!!I;I1t9l3#Ae zhGNhk;^Y{65LsTv_V>VPYu!taK5BJq)?iK_+_$*6kUiSh2Yq3&{=Fvq@#|0U?VsR* zEa)T@YMBssP+!1WYa8quz(^xKn2rY&My}lq8R^b+*e?&USpMan9&xTDA@b8haSrP6 zdu&1zZ^Fs+u>Sj3v$+F0)jXawvKp_p^hI6aQp)X*2i$r@5YG4rbIR>@_Qk3#_-bh+ z!&7G`a(=Zr@L=tww5(MnOO^x#*mthp@a`qVn3DdLp~2vjn#hwz=+tUUU(}iCA4{Z5 zgJES{tYna`-H&9g(2idn<0*_qxnzJaD~5*@Ch)RadE};>M2(~2aCj=S)QQ#;Ns}p( zNJIBm?4Y-#40=5Jf>hdXG|LAEq{ah59w(-Z_74OU5fM8siw^XM0a85{96MfnQ#qpi zo*X}!|DHcGO$Tpu22T-_6RxbWxi;SvQ-vmreSMR~kjfNu*BYB$SwgOe4f0H3cqneS zB@@MBGGVh1#UrtCZ&a`IM&ogBRHr8&d~&qYX4?^o+IH@=Ui4j7=+TJ3=v}s`WSu4w z4ymJ3F)}6R7-K`KO=oj(k(^?e$IeYrQs*U@(~;kl;Tz@frqxfg8rNU`q+(2RdFQ*$ zt3Hw0UJr|IKk?7vz1^&bA7FXw{-KAvKB_t?w509hxjoWrPJSJ+y$7;7Ofy+YMh~Gj zKhkVogUnVx*1qedep5`pO8*0N{QFg>V&Ye?$@POMcb!%(;6KL~zVZOjpJq6NPg+Ap_bhu6Q!YoW6zUzMpQc zc(Ovg6k=$F!v0!FvcPg%ghaJ8)uOq(%S5A_N!?)@)cQ!LRjHCq;Z_Q+P;pZk`2?p| zbjvSP$6s;B#$o?f(#+f|xRtc@=L(jBR%N=+h={kyzwzBvhc9c|#Q=m4{_aj>=|JX*~{Y z&vBmTBFFm$uIHOP_}j()YQxkUX%KkiRuUw)4qGI~AXlS_D!T2HA$SOJV2p3ap*%-1<%A1j?IpUl-iuj#a*J1|k6Qlj4#TTD zK=oN2l01MXQ0*b7!x!%ZPE0LGWds^^jK%g#nat?rCN!3|9BZvOv$@^y#&-U$cEjBr zAbEaM2mhuHquGv*+2sJe^9kGSQ;x^89Jgn=py;bvSJGz{e-^vPCFA0(_|M{mlBQ-I zm_oqp{ed-ovMa^TG0~VfCvVKi2lR=o>HWyZ-^$0%+)Fz4In@2M@opy_I&*v1p?`?~ z@n3qkms{>WeFj|oD@~9y_JXBllt0O!0;y-Qt*zMJ9%^d~wF6?2FYE(L*1^WLn400S z+!D68a>4YFKCD0D-{=5Xdx@&8^I~=mxIJz_r#;;XDMgkR1FAAR-ItKh5g4QG%; zop8_fZ9MiT`?A^TuRJpkm?k$yj_Uxz_ac}{x z+}vTO?jdDThe>;A2_5F;-y$zUy|2Aj+kUBc%1_83AgmL5AoUyS5i_vULf>uP9Qw{_ z!kk11hwuLj&un`#83P9QrZM@dalA2#F}!hVE;EgJV#e9o`psvGPHxpI&dxXi`Ck6z z8QV;@i7rFmR-%1xsEWFHaAbLI!_1it-#?tmeTY9tz$2%h`lfE^d!=#>_zf+mw%JQ(*#Y0wn1qSSbu)y_*>r9 z`Iy68HclAvUfPF9c(2Bj_cIHgULqtG4YBzuodD_3ah%s*|GF?*%coS~G1cFaJ{HR- zvU$gy46B7o^y!Q~uU_I-z4IX@GN)4u-dpkzopR5qYA^fS-VjIW_*mmX$9Kd{{^%7! z<=ZczktLn(?Uz^07*S4QK|pku&~Jh7+B?6)zk}?sIwJRT6TIxuzteZqPF*H!qn}#7 z|Exv&qUs%q-GnaveyRC}P}y7GQ*~!Qp9g<$YOcF66Xd?@AsVXrY`zg`Hg%ZS?B9)? zaIS;@NLP%@x;AN);ihMX2h~zDb95~a)440S_*#}A%vlqYR1uQOr9mm&SwB8&F>Lkw z4O?c-F4vqHB7eXzKWA|{jK)w@sL3y!4S@V7HNOwVr41YI+J{lfa)x;+Z()biRVUi7$$i?b8T zwL;S3d~Kz_r$LEIi|NdnxrKEPl!-0EPRp0{`IRFeiFzqi zYF~B`@xy>lie317VJhmdXM6z(I{G+%exrSI6@gcJ2F@{aFm+gMwYeoC?4ownuCk-M z$bvgxzH2|lnWgn1;!vNr!i=gNG9%%Xa`_gPzai8ZR$3@;-18nmlL!xSY+0+-Pf*uS z!fw%$iZXfen5!rjNqRimBh zo-5o{HQgis0d;kR<6qsVj?u`Z7M*%y*Bo!0$=uAF7=u837h`%;)x=@NBR5n|RfXzF z$!jH;x}m3b<=mbxds0_H0PLE~<@))2LqAs_(1?AFfr=aGbf{&oG#XwANV`yT^t0X{ zJuV-SpidQ^dhy=$GZB?K=`SUpUfeNCg7Td=uL~|_ENlzYaYr>`ChMhguCRI^Zk(&st&y6VIVHhyv@NyUw)LcKucmL?9Z{dbS z)}vd+cp((s<|A$_6htlMiUvMoD1-Z1XLS#}e_Hnz*wTfczj^UcTQ0^a^<8_1X^2|T zuDzb}TK)3vM-M13Un)*c4H`s+tXO{vy%S0nLXl)&=*&AOsq<ZdD{>Y~Q|U+`Qt>8R(3Qq&Kly8Ea&Rcp$U zgoAi%HB~l9{@J){u~81c2w#~oan%o7=>40jzFI>6Zp#APH1k|q(VZyBf_{LWUq&up z_X1MmpF`cC;)ch*U1sj$@e7o5EEOwj+kn*d?vmiA8I;R3EPLcEd4)ERMt|`im;qH^ z)wwfZUU>2OYD9ZaF&8X+1@!^HZ<&I|=-Z)dJ;*Y98s9IiLW~-uEo@+i0fU7tQ2DfawOg=B<5z3dJ^@>AVFXt7hID2&c(Sq6?>!I=d-oL# z1^4*%a0KE#fuN1D(PrM_c3L)Xu-fgW^5fp=vdnL=*}=hAy%v`k@cr`ZRtWd3+=1>} zWnw2?4a&-QjN=ZU0|!Yv}eouQRZG4I#uO$~$J;g@egTHnMHgMN@7sb_9rDwDY z#;HKN{*^fW?8F-dTF1+JE1}n0Wiq|RVAR`WCoYKne&}`nQRH`l@%lTx)o4hFqcWX1 zCy|zWU4E4K{n!f#y5(j5?OwN+M9RA_sm5EaDwj;EblI$OeU+y7$b?TJ&fPNmm8e9vz#A76WkX z?smhs*|Qzvl&gLdH^IEBV_fEz{>)E9$DL>xLp^ ztMjGvXC7d8`@-ro?WNCrsx~ZjGtSmdb?vfQkGU)lva1 zWwmQdpM_07=@QDiroLMSw?%LvE?Ay{41x*HwqbbN2x#!rKHNYD1E++s7S;WsXSA}= zTEZy9e$yBx$Z_{%Xk{?}&=J=Ilznc3?;Z79w5_ZDMN|uV+@zI1zO)psWcUhEj=dg! zY-eNZvf-{Ly~rIqhJ@xP*{>_UMy=8`Nt<=kC3P&d-D*twO0 z1(M0XNKQWf1{~&KuLQC3wAz-_@H0kNqO9|@wr0x%v=Qwcb!%6Ux|gkvd&v6c*ry-J zC!tu13Br4`P!`dqfFUvI(7i3d{_weC z9J_H$EkO64!p1M&Ksp(dVqmR7BxD5B6C02X;E%uZmYlTefl^ z(yqRXciH{#o9?5A?g8zs`+9v#%}W2S5;Ww`2G(b5aQoKKbgJaO-D16N{&u|R`*CX> z{oleG?z;6o$5Ok=GzGQavDUWwdvVuFU=?b?l4>;&EwugbIR@JS$u7to)w904v8`^~ z*iF^T{ikv|bE~mDus=f`FRh`iwbHw$+GyV(LEZfCQ|SG&dsg=xb4_UCDmXSwe--PyX;(Z^rlBwkqi zvzGN_x$WzxY!=kI^%c_eO2{|%& zE&)}ps#+G3s*b4yD;>@EE#zy}x}^t0&9s-gZ5n{;xOwI`y|t{G@JXWNC*}Rxr3LEJ zT}a)8vO8AgH-_f8hkozh`yfJ%dInWLeuAT$e;+`^?jRoBA=p~UePhIY-`v^mkn#QK zx&tC)T>K{(-*Z*%Q1geZbfBu+FnllWS^-Q{Gb$AZ2j2!&tqF#2XCAXJk@;6NxaMD1 zQBXuoTUq9-C;docn&U~c2Uz1T@Wfxh!C!-(i3&1iYbW>%a`wlqI{-42TUq>!T-e9q$YK~xq`trx3GO>}bEa`WrQk=!id zh)cG}Mpoj8z3=0AgqeTyX^gYa2;Omv{nE{6M$Q5899CCrf=ra!~?&vsrP95FvNBhw^`mB`b-ofeb9l%8E&ybEhWvKR2 zlQvi}SNm!;V?l*=Thm*SR@Z;WYICXcmwX#Odi~qu!Wg$+>DuWu+xA%MTr;N)1KXUo z6M!)dFs6wL$=Uy78tipx(Nm`gBEOM*<%B#As<>Tst2eIuAHJvIM*V_Io*X<4LaCO7zX~$%|`OuDa;)(z}tI~N* zdb~1iWvQ?3KESk6wWm!N3+rTx0Q=8ezPfPwLG>$DaaW>Hd%*oI0Ju!pEEu(Lc(7Gy zgTNP}1d@H>IN9~>eM4~Hp!W$EjTC(!c6|6~=&j!?VG9;h?`zW0l7)|~T2aJ`Xia9c zAwAlXQQ4)p9@$6#-!{Kx-1-X1#Wd8AyblACh7mv#3yeUbprWB;G|YSaS5xMNRu$0t+yp zs$NPbdIm<9mg^9VQ$X)Y28vfnJ6aYaxu=_&k)>w8(&yccYq6#i<`B|XeP!W`Bj{$Y z)huH+wVT&08MITwI&B4sjuTMm_Z=v+0k9HiISmX%EcBH;D)Y9FX3aXiwO({TcMj6N z>Q^VEvdRfd?8VSB(t)I&1&>>FLNmEj))Ts64OuhND~0DUMLR}>AbRhjT*nj3bhr;G z5F80A3a?%bXZcjVMAS=w0LpsC9K8Cfe;fD%rE|HKV2l!LrsSS(_D0xdvLI7MrQ4$Y z4ChbpvLEbuh94XGq!2}oi34+2RPoe;@D}m_$6Bn+g_CjXmLL~ND`Jjv}voXxH z{n>+~@T~F-Cp=9S$amIXicV-IcglJ~H#IHRjP&&)^V~VO^;M5L!OAKpEU_1?jGrd1 zMc_`9+I_;e6Iy}=bo%tPgyrdFo#YvFV5P7%o+7=G_R|Xr=0(F68{rBjFSfN4P(>We zsJ-b4HAE`LYHLWa_9~rzd3HieFyE52&l;4H|JXa)FoFqq6OeW_Tju@hhIA60D^>_|wVYe$S5 z`BLqSxKc$w*KoABLX3epOTRojK?&wtk|x%8iZ~JW6WQOklOy=M{S4g}}HVULQ+vYg$i|1P2=?$q^44_DgeuPTi7n4`=pN^LpJ|D7E;M}heWv%V0S zSjyR&8YY%ChE*kWPj1~>@2Dm{1l%2Z8XEc%MEqQUQ{H{229l}_tG|@m{qpUkNU(qc zpB}$F3&<==Qr{Y&h&$4LA`0fkgcch)w}Pm)dVBAs+9USDwq8Y^5)H=OOfW{fi1ePY zi>Y8JpkR4Cu%Yd+Gqmz+L6b?{`qw(B9@Y9^BeGZvAHxLKO|epLYFd5LQo!ViNjK>; za+1g|HOx&@neO$(qk+kG6Xj>QN$~{2r%kNx7E0VRb)u6RRo25#=r>D~NAx5lt0=I3 zCIk}*CW}rL!NHWGf)pFgR8JsC&`XJ_$l4P-@mUg`z~}wM!_Zd z=}wycp?WgS)=T=52k3P%7R1j>$1S8l-CWTffTK6VGQz7#4m+dJiIfsit>WZVN*yv$N=;(3z4<@{8Q`TWf z;ecX-(&QY+f`UkGX2OoF!Ezkfo*Q4GqKq?LiiM0e%UaoNA&>pkGX+ix4`Ni9aWjfd z{~4!Z;%P5?%ZI-AM`40R@XMm? zN>Y9mR?#)S7FJfRt-?A`6;)gHb){6*URm_}Q9K;Z2uC#;GdEjj#}v+?DW96DpKGI< z_IW%5qn!^kc|!Q2@WfC2G;hqCeb>F#_e~@nnO3S?3M-|4jp}BVt40@eNjKG_5sm1L zKC35VRLXqwEwkPZCppzF2c7b$_qo_dT<#jTy5BGT-a`)|%dxUp@sNTOCjpWqDKf?^ z^K4}gB^;xYvovvwR_^muOdR5M)t6|UuHr=;%}1j(o&;l!FMCp$Ln+VcT*#G-=0heG zwr>|Itg7a^8?3#7CR^-Cr@OhkySv3L>#K&8bBLRX#}UU8rx525(~0@4O{_ZBwep?_ zkjmUNDMgw^T1;9)+DbZ!G?A3VYjZv+leChwfwYBP$8M}>sd!%TE;5{G$ycQ&U8Xzk zf}0+B&DLK;wHq+`-c^!t>P=tzA?)gon<#ZdI_<94kZg7PskK*im89mWAUxhlPjlAu zT*^5waLFs)*v;NNZ;kEOdSCwOv>e0M+GK}5(vY1F`Rs}YjN>tL88T(Ptq9)QB1=eG zN!5oP4l1bYF;eQb+s{$HjqQv{&MH*8O5Yu>~D5cCFy*gvXV{@yVRAoGtu% z=0fH3i9+rKZrG&Wq?-@%N_ks_+l2>&^}^HqEBw3sC;U$S8-C9u(j+ZEfj^PI+H|Yw zxk(=r>X^2f|V9OS)7{s zVH$FZMpz`ADy(zw?Wqz~|M%SP>3nZ})ALzWPgasyWHGr&o+oc5H%fGNb~jK`tNSAxe4Sr#qPOZz-jn{2- zX{+h3C3^Q_y;Gm7pZ)&#o$Jr8+g%U2p02%G+gSU&HWGLPRkD-BUougWDp@L#8@@N} zGMqD9H{3V07+x7;+}3v+0g1DE;Q z*ysn@0XkR%R@iX^l&-D$2x9%Adk-JDvnnjVAKv2T&Fe)Y!eTkn2xbA!XhHB~Tgd5{ zRil-IGGLQV38pSQYY$2jAI`-{^0(`hQ7M*vF#V!tF~8Y<^pw8;IV}}>X5bnr^%TV% z0wq<1qrYE^^?#=Q;syV^IbH&(riM2@0I$1@1-R=Q9spRs5qzXkz_YB_wsqqYV@S0c z$aJGERBtvr>{N;#lI&z#$V*GkwS4PcL|r}k7qGkxkpNxhMRdB_d1ZQt(4hR3S z=>1`f;!K=P~M8wzWc^eeg7CBZqD?9l5LJ0mu6P<;_(W z)4TRATR6SIT0cE1IZ-1phb1On(V$59ExHtta8^Q$tlx|rQN1?YM!9g=)oJ4XMgGar zTCls`y}OD*cw1`ct%Nc@ypE9P3eLTnFtMM1SJ}BN z9N_S5%a!}w@T3d<=p9)Q&^W1l%iU&D=B7Sso{g|)_^MaV&8{x}h$a{P# zmZA*ZC*|dbi$LXoGNWvV!YOFT!V@eI^L7Di!hhJaLl)f$lsW^YP(p&i!UPa6qtQ+L zwGW4yxNh3-C;gGrY165%2ksy4jfrExb??3soB#$Rg1^@oP%i^m1i^;^0h+mHZo=E^ z{AXGov~s$^qB1fFhp(UhbH_44Yxvq(lc&M*inlJaR0O$qJ3_vZ0@DFO0ZcV{-4OGU z?&Iyoc4ksj8;yiAK9hWWSU#o{X%XXLGKG>o(M!f74dvA#C2jsBxRXTHuRh*j3sHu~ z#v=9~$;Fwx_z_t7G6=}A6({Rz1p8({$t$jtVHu0(5B{2ea`D1Sflw)?FT! zI>7oNxak= z1W|Yo9&r{7V@s+Hmz$se+uzahplZLz#cfzv@iGYA5AUoux|kWV!(lrD0D~nnMISp& z4+M@b+UNZs5;eVakFfKN=;67e@>p`d&dA0W3`WX_gnWwv&|E-P4Z?^5iYB-r#2*m^ z($E$Q`5}UfXES-{>MpO0vN>P)XHu{WZMb_%DbwpV=m6<-B?g>z)uz9sF!G;3SFuGd zcnF|y0R94cAy-;kyb!Qs``Oofb?Nn-~f(iFZ zMFqVS#HHc<5)|f;%UpumB(K1`UYX3O#(ZSHXlL-5Zax6-;Gw!ckwzd}L;?rc7g+nH zIFC`nG}A#nbcPekf;UYn@WT_&zDDLNgMc0GpHEfynf4{amA8X7VnI;t{zFHFW`P{F zR1FBO^SYJZ-m9fH<6xIl9u)-dawTdU^jh3_c!oGc?p2sdQP-oNn*bV_bI*nxb4G2n zW++CrvT^gi!zY$le%!zBNTagVxDk3g5OvJr@o@qt3gqw%KMqfpQGQ4LC$6Kr*bhVMN1s;AeBq6#VlL5Y~muEhj9;p598fg19z$vt-Hxh zA<+CG%|&O;LCHImPzP2T2Lp+)?Pw{k-Hej!udN>AWp-`^ociy55`W ze<}9ZsTSq`8l7B&D4TvdcKY0!yE9OwuJC8t8jM+|ayWP)dpb{}Nh^g73u&$1zL23N zSzJD3IMUNsB9{=8mI2JEbW1VH-{);~@tJogxOZ}}HhBQN7GCb`B~1mxlDVFDMx~;J z${X@4RV%^aIN3`eRN{2%1*}LCqD6LGJj5QSSwn_Hf4sv8NK*?b>|^}TKV;I;(&2-^ zQkt*Lbq{+A$UUVzD7@w}8-wsr@j)9td;<=~hT=CAC6J`4guS3$1_&-;SVsD{9S5FT zf9QGF72k{*Nzw#E2!*Ol3k?Nyd@lL0c#!6YkJ_Uc9IRKm2j8YRYwSB4>2R5fX%GZX z{3ngPb&1?yEF?FT*ZT`&1^0XG0&w#ZJcmJNnw8zTiGLu=_ipp@ugJ14(dThHO=hcJ ztx>BY{I8+iBKeQIoTtTNcWycWa~4n`NJ<3iiU=J7i6TVp9=#yz8WW;^96D2n$7%;K za&qF=$MwZcYHy%abz_{JK4Db--bn5m_X=h&1)zTvWMyA9=Y7q^W^%_UVS>*aK zC^4h%jS#&gi(2Sg{#(WS!V>t+O2UiDI3szdBxgialDqshMM!<*oTY?tSP;#z!C{jx znvAtpFrb%gbn9X=9HnbsZ7JEvZ2huGWXxZ>3WO# z=lfEpp%jW?f%)Wk0+XStw2&3cYYsDbFvmb!_0`VJw=rbJ1E$!0XiW~j*AxhQCQ!x+ zdn91Gnzq2Qki&t|f$^)bIkl`vlw@nUJ#+A2+h#E7cnwJAo?8bk>)KoGSMU_3*Q$c0 z^dC|EEnIJ_nk%zlWoikLd3_j;8tZc3!567*_im zF%>u5;(7z)sy5(Q9myh^ITwh_u&N~%Pez>tk4oif4GEf?&%d1xg09{Rr#n6bQDRvr zLFu}kj&ruML~J$r}+JQ98nX&tjAt!j+<$>`e7` zS_eQchtMzgG85PACI+-knFpFzb@z~o+kMcUzq7Rso*PMnA%~zfGPt62k7sFpR6>F# z+DDLB&FclNQ4GV8Q5qR!M8-1{)>d|)5DO=8efVym+GGnD4`w4Lx>@AinL>itlWm3D zj-5W&BoQc4;-w&M-W-h>`QzRSkxef4I%iq$dbR{+c*xBku`~|q!-w737)fqi2TCa+ z#^P=%rA#kg64AEjQ_6ttMJs=gi*nDlx~6QDmq5_W$pho-NwIY1XGbBPETpFbq z%LSGDzY#D=C!GPv0qGfm7J4n4@v1TeZ<8$qZy&)?-(L_ScV`u_q!1`alsevWjS9h@ zoOMnyuOB7jWW_Cj$CRLqp?=G{M`D_&zx7Z13fAt`o4)RJh>jt*+9J?z3fyz2NcpXR zRuyv~iN7D*lss?N@aF!8zF-EqQHIMpPmkQ#gW3*%j(}_PqjT6ZqDm74GQ2^8_BX2c z0I}la>0i%HhW?qmXl`ytkq5i`RMJ5#XGuhDG>@5>xnLpJfCU3p60aM%& ztI0cVK=a}hrNERe6=m!2%R(tgji8}6pLTHH8C}PaP|D2it2Nh<#wglE6j~Gpz&iEyYR*xAuyqv9+J~28#L>%Su5Gy97XruriQu!LVC*?BYZaN14+>RlP zcjpb<6S8Pd<|eDVFYlDfn?oV*P(s5{SAQUu1?BRtgB0mi=`0k&8{rj7F@A4fMVqjw z)XPA~5^a<-MmlTPbyQ5SMh{Z8<;{*6E#3>O0!O45&7DWs%p_RWB(QH5v?)!M6CL}f)oS}?@nI^$R(t~Ef~6KTu|N(hdJ*OF60J{ zwC|?1vHiXWbA4{g=`{3N_4X+Z>8SlTgpZ~@RDI<}b9XKmpEv9o`LJdf^5>Z1u`5C+ zv9ln#&XbP-Zl)tQOa4OPb4|uwRhpn-6q~m^creTnbAh^2!H8wWWXM>PUs}zn8iWps zu&qBFr9c|TjOhKT=F0wc+LhC%3rGK1{w7&RA8}pWxB^+XsOs|i%u;V7=+X~NQdZUa zvqfRGesq5RW8ODpOo|S|f#Av3Gcw!>vq~kRYWel3e4SU$tt`B2rUq2S3eWBqe|rZJ=8 z(@8vH?p#=lbA^U)9OD}C?8q^%%S~Y}+YI&PK?e=st6GxS(Cz54-V6Uouq3uB$?R8iX2ppCd|8nHnyt9`3z61e7c+G%W zA$@m3FMP%0z*?`IJ@RecU51ep`xAPm-avm+5*9=hxmjk)LYHL)3GIh$ZIJ&f2rzuh z9TUKb&i8`AChiOs#tI{GNY;2f*&F=5!sf0IADzhco7rY7jDqBp8f&Qmmvc(D&Wr*F zwpU{j&37^E28s*%4Z_Kj_3et7EEmBTve5J*>PkqkTBwo+ zI`*LqJoOc_fvOBuVE;W2(wgjHNa=`#N|~4>V-K#xoCKkZj#z{)^a66GW4F?^DA3J4 zAslkZS>3W{L9-VOJ z#}(esDRCXREL& z#k-u(Nf$x0_)4&lkA#iLkBo4qvcg7O`QN- zc+%zu5S|zgmTJ9luNcuVlq2mpm{$4DLc?a0eWD_5n;i=f2l2F#h=zs?3VNiDqJ3Wy zn&eXKAJ{Sm-v6nhy*gS--dzxx@wQQIgOu(gYy<)Jplq(hH>BtFrdiyMAi4~9x*{vA?7>;xo99NR*_^y`LY_@(ewM8PCn1k{-P9CdieRj?w<<13z+nSFZ zx)khc;~0$B;n}kPAJ*yRcKFd>fw=*cx%hBzceghyCBWD22UsP-P^M7Gqahyj+@-YJ z9gJWK3M)|n`kIk}%8L;KgLjV^I?eT67CYAhk@IUEd!?>32+NVAmYu;yHp}3$)moj= z2q|^v&PX1o`QcpPy626q@`X5N(Dx8z39Z&(Bsna-u_KVef(_&#+Ui3IaKby(#M^_h zXz8XyD+a@`fXQUB$XY8@GvDngU`cyLSK(-R$b@_}pH-T1Gi>&6&wMZS1F0-Z%#>WE z%dNtfS~`w(U^5EIXfSJ>5V7>1IO0_1lgZ$4sH96|If3*j?F5LQ@>e(ssk=vkq9xP} zj=RW6?8$^@0K4rZWr7)6xN!m5=Jf{ysJwWdeBSA34-RlAk2eY{7E^+)v8`SJ#l{Kw zB(*5J3{_UVLAHOIauZG(R;Q-ULja(Nvosiu-s=(L`&t(XK)oJ&jbzT^O6T)HTDS$N zSfh~(ISjmzJr5i6X9FOGAnhd;c>jp>$_zyo_XM+RDxB(Sip3Z|fxB+DwcuhfUWLT- zly?E{k#4rE#WOh&2yki<%CpSQjySuT*olKBbBBK{DaTISxzUg836^xT-v9HOcI$AT+&dvS*ydDpysfdAP(1L=*tyda&8pN z!N5s>vI3CLi>+h;FF1vTsq1CxXKG zk!G55=TKqGo~OX~CUgxVAv6vSsQHse*If$ooZMkb;#8!wGmBKzYiJ$j*Gh#9jtG7Oc9^YVm(@B zdu^>Bdc9%{y%8%vE0a)_2~6Og5}*+J*K0O-5OdcQvHLlY$J^I`qAOdPlSp_p@8r-| zxcrlj+!2}*5$C4~9{_^ao0vFk7EC}USZ-!U=Ac(j>0C_cdZ9!x9Tx{FY<92r)5dyO zUU1;C?dhuE^I~kHiK1W*wVR^TIHi(DM;b{Q!9ay67wM#q8G{x2w7s)Pm#i;Oq;2#( zZm-v&-%Vn*-tO_bJ(0`+(Nhqzttx}%KtrrPhotU%=hjIM_7Bm6|2eHEBe|oK#Mq8f zmd+1t=1gk$6}T>UcjiRrN}3i5*<_imem&3fAl0Q^my1n_Ooz5nge()A5k?2{ZJ^z$ zc#zYN_8>YiZ9kx({@v8|mm=1~Zq8ORA+CsL4`6N0!X1Cvdd{K79Pcy>sX^lbKiKEF zJ@h&qj>2lu%_7x#eL_U+GtzrJIz9)y%Jdm)$ir7(%GM}-gIfJDi71qq&F$WGX|Mge z>JyS)iG*bZ=quhL9TmW0eE^rb(O>|oL6}&M?;my44m7K=} zps?f`6&KMr-C=di(mf}md(<9H1xw8N`STET0V6m|eF5e{pBUeft2XO_f=+tO^cA=X zo)&kU->A0~27^W>hlv+VVPb(}$^x169*j&zFmkVPBbwT5s!apBo0d_GG#XPy@{Iil zJYJ{2!)Uz8y}xuVGPP6lAca#4yYMv$ya?W=n83lBquDc_Rv-ZOs`J7;_1t22IBCy6mjNH?-(GE( z$@vk`nt+*vg0$CWwRfued_FZv?pYBlUqkGv=O#mAe!f)@kJZPF-8ahR|c{mlp9+yx~LCDoqkMX$fq)mMg$=ho`?{G$`tHDe z_G%RVA2;_>?|9{lJ^N|+?a2eYyPAe@*BJC=iNdh0BL4o{FM~3yg1#a6uFQYZCgBI% z^4JE@-eRHwJqX5`S0>mlmQQ^3OvC-;v})J5-6EbO?Cq=);C({%&szuZS^4=Wk}g*V zfUnP_YAp<4+=$&E1ndkCp4bJwU z6VxB=AF2T-lKmHt`s;OS`FD+-EiX$&VYuK+DHv;8YO8OmzfgRGJyVeXOoOTgr56Q2 zl`D{h|6N4CMAR<&`&iWn{0yI2+~vL}sdREM!|X(t>$!@V#jrYym+X}R zt@U#ZEVub)Hj&_`pMIVHQFFLRBH_y|p>RNsPtNQIZ~n711P^CdUed!^DViC4&i&?d z@6jIzUAymTo&em>Oei>i-%+9;jGpU%;?|0+wt~~@XlT57CVT^49zE(+sX4-B7!pCR zJ2`MJo@)}`n{+69s*GLy; zZ>kv#{&5G2M7FJyZ(WBiH@l}kdJ20VJ$3#ioO@}$(}OR*#-`8y5*st-GXiK&ilzk~ za|vpvCLlDt=9{6-TNma}yJj}_R(Vm31D_^o{z-%iqbjqSKA8(?)wy^?`^xH@NBiC? zP5XT5oUw#5-)zI}V=u|NB#S10-P+wM=w1U3t$r#G)%0OL6e$7@?@$j{%y zc+*Ut08s#W^3>0U6E%GY2w1}VCL&tOn5qZHaBjlMsUkWOy)#HjWTU{MK)=QLLpVw3 z0=&J1AbQl+=3T>CN_2Za7x2F(UMetSV-NSOAN=j8;)W}stDjbD?hi99pF6nfd5V7ztXx(A^*feqbE6w?}_MAap zLJ6Gd+fFY`DkXOnRmg_50H|fq-rxvz`V>*hWzA?*90rX>L<;(8Ru z?N~mYIEK$*>vY9qQ<+A2P{gNg9q6=KoM|E&G+nrrUcq^9XQPa&WSaSrfm#om>gGps;Icl(`58C2B2ILE*-)Ky_ov^cjGI@@3!J&e|i@i=Da(D#X>X*PHIhhEJwmk__mlBq|c6uUPKbzBp6 zzIqra45PSEIPLhVg`k6X8wiAo+`EyldN<$li|+aewYK8qaDC-~=j`df&?Bnlg^=_8^;bBz8C5 z`xZLNWNJ8WH+Q|C$>m3}o-j5FwD<&l9WsQ`jir77lKND8`1^rUL)CfH00vY%8WFAI zqv8*BC%W7^IK51`g6a*6T~aT|F7c0$LSTB@0GA9O9k5s<(9{~#dbbcKghe=sjUkmI z8;z*cW+R!2U?^D6akPGKBC-Z8LaJzRFp`?OYLt z7ptZdEW{BAFfQE3bj{KM#DiX zBu5&OkwXHZN_mQb%m@_18m?r=8h2UQ-#^V!Ue^QEzsh~V^@y5W!Rd79r+M%V?;z2u z)qh&bLG3$T%uU%&lUa)@wMxUq`gFnO-g?}IEajk;?M(=}+@2sGuA_3Zt{g33AdLeE zSjNXB*lceJav5{bX|gzaF@YV$-7YrU&VGtUB_|o9qERA~3s`)yT*VdALmU&PC>9-Q zGT^!|ImL-EEHt0wf&m%8-~)(!e}H01v8IqN{gaYH$*3+C|XR!V#N}} znE1Xk@>x~JB1qUiV+)FHlW_ zUuPhi9Q;BDey=J9{Qmq%Q{}F|WWo3Y%^^gllB6hdrEU zw32pEG5-xyGR4x7s~w%j=JU(YxF_)mu#r;y=&St<+^gtoi*{1zgo_fzTQ;GK@EkOa_1M zgN;vuw`6rPvM$U{9L80#5rWlK1L_H;`Utd+2Q_H5D}=os&Bc|R-9=3~TXi^E$|#CV zdA=hir(UX4wKi{@3pqv;?GF0KC{kGg=;-j`a`fctxqT`GME!_uT>`xiw3e-2LB{D; zp{3k+mv}nc>VF!N&2EcU7JOFC5hu)NppaKT%VA^)kX(#C#{656EXBG36F?~~$)=)i zD|pA*vBL$Vkerlr$tT#|r01Dz2G0MjJ~4hy#;Q4wjHCnvqpchy}li1AUkVAJMqnORCuIEN}B-tZwYy8bOa78Oc~BB9Q#3B5@u2`zV9v)~jtJa|B)(wO ztShnL>hNj?;SX94t(&it5b>@W-ObJk#P8D{qy11Ortfz59HmVZ(p8RM!D0~|SzZ$# zMkjmL%UP>-rO_$Jq;xD0alrc%_f~hy6_`Dm1}~ikYl=|y@H&Wrh1W#X8i|q5BZ9sg zw0W+HgGgpl+@>Rt&F32%b$AdNuE$=J!wrQwD3y;G(ZLkX4y!Z#G*gD%zFp?LbhicR z;p>82@`BsBp$K72l`wv}Nu{xSh#Sx^wO-dPv0MX&XpvG+&xUX8=+(C#s&Ciqp#~X@ z8Fm{(d)7qKKtP~mM)MmzVRYxKoS$nlE5v&qWpUeS^gE$eM}}8;HbpN`Z5m&Bq~^iAZ|nx(wOpMKrlt>mzxK`a zzg-H#XlM|nkA7qf*=)T)RjvDrEZG5c){~TNw!N`3z*BhK9 z`S_FFMW5HhxCAu?XzjU3t-iityzD0;5=8sbYkJ4>Xxc@9>a{K&HIICX6_kfvINT@7 z6B{NO%X*opv0@%>7;;wxkSMs|6UZo;4}3g$SeZoE*zSdUM2l!6EHHzocamaB2Sj zm$%^21>QcVQvZ0wI^lDEdoT5qH?ByBUib$m=G}o|gZSwiZ6bopJy<6pp+z=a|FdBk z-Q9!!S!uBW*cST%ZTbCfH;5mSmmEGff&G}c-#?E=jhd%z)uVc@UN2)~J7@j>*PbEH zsHa?cML+yXx(#us34^ab4{={lj^xVeL1g{DF}@B?T$p8{zkhP7?RUE6LF< zeK}n7&&B|=8yo^ga>P4Br(GVZG^MR6*=%x`8O>IVpG;+K|I zCq%Ibuuy1p?CoL+v5mfI2t#qkjIJp6?d@-aYgA zj8Xvb`PlavUwAO&NaCF@nXL9wL~hh{VMGHEOhvNw?>^7|KVbRFT7m zz{iI*?@-<{WI;m@cWd`GTSdr-KEi~}6y8BLyf;28Djc@C zP1GkRo3+Ui&}I(C2$2o+$;2x{@Guhn_NKSSx1(RHiYCgge^f_IzSj$Ndy9H>CgTWw zJ|Gl;9Ze5HaSW&HM9WV^$DD(H_c%zA&v7UI$b*ERG2BuWe5Upt_c~BcgKc@ zle=I&U*(72OUZu}z*@a(eQ2rWbxP|g-~MyRPt+UV9m{<98)Ob#I5z9T9kawX+rKCW z!DjwrM`$$JS~W6@{6@YfbznE_m6Qj7kmm;XX>enosYhR$GEu%#&yT?RbQ0M$$9%mf z#yQx0^+F+Y29kn)&TseE|07=sQ>gfNBgML?R0_r`q&rn7sCKF*h(oSs%GO9^HsWJ5xdjuX~)!?DQJWM@Uq0=Bx!sgb!7r z{3KCs92895%Z`$FG38t=TJ_SM8r=Nvz-%4GAhG} zp|&cUW2j`yusWUb@h(D+j-f8(BoOm15Myk7|L`-*~Ak2*fs;g1B&^aiR0c?&_rJ}mr zi^CAb=qf*xh4L#}f?d`{Pu5>>4#g@X2>6ISHAETeotOk*$_#Z025IZ-c73Bvm`AAsJ-_!Kfm;sIY;mRF<7?Q-cTw8Qu` z#LC#RfX$a`%p^?5ebO|fPFi>YYwj+Vpxu5&qY(?u3yrrc`s2`@?6gjpa==3kzScGzV!}a~}!`Uf%kkgW}pPxI%=Mn9=te zItG|W#wG_04MgtuC+#57e zs5RAEI{ZSlEVZ%W8082{av1ojHa_t&#bSzpTxE+z(SgN8Zvjp*BCQIQTsE6)aFF7y zNgePn(LUqmKIJc8kETtdB~o)5Bc2`v*M_<+%Rbv*`P0S^7#ziU*zWr*oGnoW=kv{7 zoW97pG$d14{W&3>z??MKN(@G6tb)#Aq>~BruxH9o&KYUP)Y0FR>i=pk#zrv6FlaQl zLqezeaiU04KPty+r_$X&zI7&FH5}&mOlBn93xG|*X%Z)^0Pyn{ zJSPIcFW0Sy29`H_JTPzom;eR<0`TA0>66eW74scYC2P~hcV+;mTX2pQkte8OGDcNT z)EjbGCzy$mP!#zE@v7;Ypctr^$|eYqiJ8oZFHV1z5I}?S0N*RdI4G2{av2ct%bHo$ z4dhWh9x87gl46_*k6#?P*6iw;;}FZv(JE-BKf?bONaWxIf-xS}&`M2D#hY^UHD z-sW?S7xx~os^6~AE_&D;V=9J7nZCFEEMK<5PyiNM-I>Q~f&oZ&j=xJrD z*uuPKXLFtc5=r3{d+65n&Lonx7*ojcN``%>pK%a*qm z#C;oBGdOGgKV~`SZ?Qnqd6EGD_Nhgk!j#{>c9iQB+bysF29YdhB7sC+_N0P;vD3`6XNabiPC!@?YXb~FMs0qK5RG+N53J@>{ z0~o|B4gsz!Wn?s2DB!#np#pFHi3V=#Z*-s!gBakjUSWd4relG}mSY2RGzAC5dptd0 zk2a6X6yPD)Khno$A7p^@zQYio9mU7;WSxhF5zT_1CO~gI>a4M_#;k)txdG+EnTd-< zcpH+PUWe6y=K>(Tu;L!JYRTlyqmd%=t?aaXAIKXt&U;(iY;jKm2N@^@Xls^g) zC7^TW#F=ZC9*H~>#$#Bv@CB9~e<1;|BN3_^r3Xv1<{i@$mxtoY;VqvA6bIHG8Izkzy z2#BCI*2x!pY zh2-R133+=Y%!}6aUKEk@U}kRNr>DD#&}8C$u-j>lg9t<-3Zfz!C@Vz_#6&E_hSZwi zbb5%3c+97e&z^%{3>VcZ9mg0lc|mMJ%VndgQm#DtLbNnR0e3{||Gz20iLNwYfqm!d+xgHZ@BTMn{NpOL*Ym? z7EdHo0S8CZ?T>_my*QfgSZf^WcTQx~sl`h5i1u3pUl`Z-=o-J3A?vXzm+=Hkx%$YW z3k^I`(U{LY^_vJj3`3t=HLTO@FSxZD2A`$+OEh?`+_k+R?_2WE;#ajQFtG+<)aHHP z5NrMYgSX##_dScAvtW6{haaj`Ki1OUG)=a8QESR;!%Y3vmaU3@tKHy#hp)}~)W1X; zX~T<_0sdI}=Wo9KZZe(C7t7U}C+UB-yS>0{+7bKypXltJvzDI~ @dw;1cR-4`7 zbh$lVpFa=`g(J~eoGSkPlS*f@xqP8mDp#tteuR^E)m=}$4GaYXi3*}_blY9`g899_fud1R_6~p$zirU{`yxn+ zW>}6FL`hauO*c%-cHFoVeO$0$sR|N~VdE$_*a8QrU?6OdSZbL^J?3#wSnf$HJY}U- zR(o27HJ-5+o;uDFv2~vH94uhNTq#1y_PiJT-+C|Fz)fzc?CB{g74}=A024Rb*~|{Mr(4>IoMa>3^&VY3gj_B``_4Y_p^xlj zlU-^kLJ!{XmVXi1jWsL#C}b}uInF%c3Vh5#O8Acx(BXE0$^CafDo>l5TUy)NJ5o&S z3w!-%=*`=ANt)$FS=CM3^}~ps4h4Y-Ob`S~PzWkPBj^N!U=l3I<0)h8qK7W}c)~L@ zJf@r8ViO$Vr-&R}yd8WEB2rIS<*MK3HKUA|@(bC+J6bG(Cq=$DukQI=RT@oB?nMih0l7nZ443qiC zfcyq2m5vI^h~j9E(4c|Mq3z%nhEg}2!yjqsss%$|X`Di;L%tsQOG?U^VVuc{Q^mrm zt79`?ZI?MEx4Ejpf~`!4(kvK!+}5bselF(CXB(M^HUmzJDxK|0gKOwU+=ELv3v7?A zj=cEos^C>gG|yk`z(1WyCD|fEN3N|ANdw z7-~iMo^hB?23H_0IN1QV%~wTKcbiIxUhBNoEH$ubYBpJP8k-KNFn%GD>viGqoc571 z6U`MvLM5Wl%Ss*}mK&Cz@#fYOWxXo$rOQ6Z>6msI-Ark!SDLCg1)KIFS!g)_pp{%v z(@n!`%A-1{MX*&Chk?9GIgw9!eJ8q)#8pmyp0`kHbLiE$$7Lho<;`fx*mQKuq&~#! z#YK;jI1I#9ibM=W?&LfZ!a2!?m0&vKgk0v53Gt2>E?$`C4}iH_4KaeEl#u1It%v}S zCP~t!ui2!XWon2KS5=a+TOVb4wkZLYVMipe&XfEd(9>s(P9lx+)BE%LmfzY4K@Ivk zK&z&2!z)hXtuC)N2xV5T!cp1QS-sbX8O9P6RCeoQ%o9p=WEqCrF6%Z3eYyX%N5k}; z4Mgxjs4gB87UPw%&Ip1uq$TZ?PW`E!%5zXK!;OVafAdy!j;F5jk^=dLx#$$P#hi}0 z!4y6!IbgqCB=aobujJXB{OxQ$ceVjO00bXR^Ugi+1pWc6CikY&*=?~`IJU-(1#)&SdrsH;p}t$yxEPpHkDL*vXoSANdT21px?=ip4A1`HFudu zk9h*r&rX`2Ij}ZTH(u$XXTkqG2Pap|nS~wu`~Xf&gw633Y@$cBBw(zun*80ZRe>C6 z0>O3^0OLf5sUxDA?!mXCFGd)Bsd9b@s8*cr3GGX~4+E+Q{`Ta>P(e?_o#n4#m!6t< z@@r{yVsxBu$U#9w+qP&qzRHS{DBdO;1f2a2`xHK~GnaL}MbwaCIbKjU)kaosswsvO zBt=6iYccUPkO7ixXbP0w^mcGPcxf9ZMJ92Er;m0_KP20@=&Q`~9{oqMC24}ulcdS= z8ZD`Ppz{Vm*8l(orWCND008iXf~FJz1l`2(fTq@QYK`t%cEk#UOslg5PeO(o%1mZ* zQ<&0JrZ&+uGwfWX8z}YG1A(cy?lBuf!Rx3mp_c;0ub#e_c~%gXs={chk8pY({}dd!&=z5kIyF%tx0FxxlFjVnZYxRA=@qDh;lN8jbPu`M}(g+*HY**0?vFkHKGzAu(oZUVYIC7TK@&$#}bhp$7l}066)uq5uE@ diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index 3aa235b056..6f603d0355 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -150,8 +150,6 @@ type ArcaneApiEndpoints struct { AppImagesFaviconEndpoint string AppImagesLogoEndpoint string AppImagesProfileEndpoint string - FontsMonoEndpoint string - FontsSansEndpoint string // Customization CustomizeCategoriesEndpoint string @@ -321,8 +319,6 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut AppImagesFaviconEndpoint: "/api/app-images/favicon", AppImagesLogoEndpoint: "/api/app-images/logo", AppImagesProfileEndpoint: "/api/app-images/profile", - FontsMonoEndpoint: "/api/fonts/mono", - FontsSansEndpoint: "/api/fonts/sans", // Customization CustomizeCategoriesEndpoint: "/api/customize/categories", diff --git a/frontend/package.json b/frontend/package.json index 098296e09e..482bcab401 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ "@codemirror/merge": "^6.12.1", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", + "@fontsource-variable/geist-mono": "^5.2.8", + "@fontsource-variable/montserrat": "^5.2.8", "@lezer/highlight": "^1.2.3", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.61.1", diff --git a/frontend/src/app.css b/frontend/src/app.css index 296dd55a20..10eb108cfd 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,67 +1,43 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@import '@fontsource-variable/montserrat'; +@import '@fontsource-variable/geist-mono'; -@variant dark (&:where(.dark, .dark *)); - -@font-face { - font-family: 'Mona Sans'; - src: url('/api/fonts/sans') format('woff2'); - font-weight: 200 900; - font-display: swap; - font-style: normal; -} - -@font-face { - font-family: 'Mona Sans Mono'; - src: url('/api/fonts/mono') format('woff2'); - font-weight: 200 900; - font-display: swap; - font-style: normal; -} - -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } -} +@custom-variant dark (&:is(.dark *)); /* BASE THEMES */ :root { - --radius: 0.65rem; + --radius: 0.6rem; --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0 / 0.6); - --card-foreground: oklch(0.141 0.005 285.823); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); + --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.606 0.25 292.717); - --primary-foreground: oklch(0.141 0.005 285.823); - --secondary: oklch(0.967 0.001 286.375 / 0.6); + --primary-foreground: oklch(0.969 0.016 293.756); + --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375 / 0.6); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375 / 0.6); - --accent-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); --ring: oklch(0.606 0.25 292.717); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0 / 0.6); - --sidebar-foreground: oklch(0.141 0.005 285.823); + --chart-1: oklch(0.811 0.111 293.571); + --chart-2: oklch(0.606 0.25 292.717); + --chart-3: oklch(0.541 0.281 293.009); + --chart-4: oklch(0.491 0.27 292.581); + --chart-5: oklch(0.432 0.232 292.759); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.606 0.25 292.717); --sidebar-primary-foreground: oklch(0.969 0.016 293.756); - --sidebar-accent: oklch(0.967 0.001 286.375 / 0.6); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.606 0.25 292.717); --surface: oklch(0.985 0.006 285.885); --bg-surface: var(--surface); @@ -73,40 +49,40 @@ } .dark { - --background: oklch(0.141 0.005 285.823); + --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885 / 0.6); + --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); + --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.541 0.281 293.009); --primary-foreground: oklch(0.969 0.016 293.756); - --secondary: oklch(0.274 0.006 286.033 / 0.55); + --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033 / 0.55); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033 / 0.55); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.541 0.281 293.009); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885 / 0.6); + --chart-1: oklch(0.811 0.111 293.571); + --chart-2: oklch(0.606 0.25 292.717); + --chart-3: oklch(0.541 0.281 293.009); + --chart-4: oklch(0.491 0.27 292.581); + --chart-5: oklch(0.432 0.232 292.759); + --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.541 0.281 293.009); --sidebar-primary-foreground: oklch(0.969 0.016 293.756); - --sidebar-accent: oklch(0.274 0.006 286.033 / 0.55); + --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.541 0.281 293.009); - --surface: oklch(0.21 0.006 285.885); + --surface: oklch(0.205 0 0); - --bg-surface: oklch(0.21 0.006 285.885); + --bg-surface: oklch(0.205 0 0); --glass-base: var(--bg-surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.32); @@ -611,10 +587,13 @@ } @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); --color-background: var(--background); --color-foreground: var(--foreground); @@ -646,13 +625,14 @@ --color-sidebar-ring: var(--sidebar-ring); --color-surface: var(--surface); - --font-sans: 'Mona Sans', system-ui, sans-serif; - --font-mono: 'Mona Sans Mono', monospace; + --font-sans: 'Montserrat Variable', sans-serif; + --font-heading: 'Geist Mono Variable', monospace; + --font-mono: 'Geist Mono Variable', monospace; } @layer base { * { - @apply border-border; + @apply border-border outline-ring/50; } body { @@ -662,6 +642,12 @@ -moz-osx-font-smoothing: auto; text-rendering: optimizeLegibility; } + + html { + @apply font-sans; + font-size: 14px; + } + button { @apply cursor-pointer; } diff --git a/frontend/src/lib/components/locale-picker.svelte b/frontend/src/lib/components/locale-picker.svelte index 8854e316f9..85199aedd8 100644 --- a/frontend/src/lib/components/locale-picker.svelte +++ b/frontend/src/lib/components/locale-picker.svelte @@ -52,7 +52,7 @@ const updateLocaleMutation = createMutation(() => ({ mutationFn: async (locale: Locale) => { if ($userStore) { - await userService.update($userStore.id, { locale }); + await userService.updateMyProfile({ locale }); } await setLocale(locale); return locale; diff --git a/frontend/src/lib/components/sidebar/sidebar-user.svelte b/frontend/src/lib/components/sidebar/sidebar-user.svelte index 4e8adcf10c..13a1cf3799 100644 --- a/frontend/src/lib/components/sidebar/sidebar-user.svelte +++ b/frontend/src/lib/components/sidebar/sidebar-user.svelte @@ -2,22 +2,21 @@ import * as Avatar from '$lib/components/ui/avatar/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; - import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import type { User } from '$lib/types/auth'; - import { mode, toggleMode } from 'mode-watcher'; - import { cn } from '$lib/utils'; import settingsStore from '$lib/stores/config-store'; - import { m } from '$lib/paraglide/messages'; - import LocalePicker from '$lib/components/locale-picker.svelte'; import { getDefaultProfilePicture } from '$lib/utils/docker'; - import { SunIcon, MoonIcon } from '$lib/icons'; + import { goto } from '$app/navigation'; + import { LogoutIcon, UserIcon } from '$lib/icons'; - let { user, isCollapsed }: { user: User; isCollapsed: boolean } = $props(); + let { + user, + isCollapsed, + autoLoginEnabled = false + }: { user: User; isCollapsed: boolean; autoLoginEnabled?: boolean } = $props(); const sidebar = useSidebar(); let dropdownOpen = $state(false); - let localePickerOpen = $state(false); $effect(() => { if (sidebar.state === 'collapsed' && !sidebar.isHovered && dropdownOpen) { @@ -78,8 +77,7 @@ {/snippet} { - if (!localePickerOpen) { - sidebar.setHovered(false, 150); - } + sidebar.setHovered(false, 150); }} > - -
- - {#if $settingsStore.enableGravatar} - {#await getGravatarUrl(user?.email)} - - {:then url} - - {:catch} - - {/await} - {:else} +
+ + {#if $settingsStore.enableGravatar} + {#await getGravatarUrl(user?.email)} - {/if} - - {user.displayName?.charAt(0).toUpperCase()} - - -
- {user.displayName} - {user.email} -
+ {:then url} + + {:catch} + + {/await} + {:else} + + {/if} + + {user.displayName?.charAt(0).toUpperCase()} + + +
+ {user.displayName} + {user.email}
- - +
+ + - { - localePickerOpen = open; - if (!open && sidebar.state === 'collapsed') { - sidebar.setHovered(false, 150); - } + - - -
- {#if mode.current === 'dark'} - - {:else} - - {/if} -
- {m.common_toggle_theme()} -
-
+ {#if !autoLoginEnabled} +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/sidebar/sidebar.svelte b/frontend/src/lib/components/sidebar/sidebar.svelte index 4cb9c36021..63e6fb94b3 100644 --- a/frontend/src/lib/components/sidebar/sidebar.svelte +++ b/frontend/src/lib/components/sidebar/sidebar.svelte @@ -24,9 +24,7 @@ import userStore from '$lib/stores/user-store'; import settingsStore from '$lib/stores/config-store'; import { m } from '$lib/paraglide/messages'; - import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import VersionInfoDialog from '$lib/components/dialogs/version-info-dialog.svelte'; - import { LogoutIcon } from '$lib/icons'; import { environmentStore } from '$lib/stores/environment.store.svelte'; import { fromStore } from 'svelte/store'; @@ -129,33 +127,9 @@ {#if effectiveUser} - {#if isCollapsed} -
-
- -
-
- {:else} -
-
- - {#if !autoLoginEnabled} -
- - - {/if} -
-
- {/if} +
+ +
{/if}
+
+
+
+
Language
+
UI language for this account
+
+ +
+
+ + + + +
+
+

API keys

+

Personal tokens for programmatic access

+
+ {#if !showCreateKeyForm && !createdKey} + (showCreateKeyForm = true)} + /> + {/if} +
+ +
+ {#if createdKey} +
+
+
Key created: {createdKey.name}
+

Copy this token now — you won't be able to see it again.

+
+
+ + {createdKey.key} + + copyKeyToClipboard(createdKey!.key)} + /> +
+
+ (createdKey = null)} + /> +
+
+ {/if} + + {#if showCreateKeyForm} +
+
+ + +
+
+ + +
+

+ Keys are created without explicit permission scopes. An admin can add scopes later from Settings → API Keys + if needed. +

+
+ { + showCreateKeyForm = false; + newKeyName = ''; + newKeyDescription = ''; + }} + disabled={creatingKey} + /> + +
+
+ {/if} + + {#if apiKeysLoading && apiKeys.length === 0} +
Loading keys…
+ {:else if apiKeys.length === 0} +
+ + No API keys yet. +
+ {:else} +
    + {#each apiKeys as key (key.id)} +
  • +
    +
    + {key.name} + + {key.keyPrefix}… + +
    + {#if key.description} +
    {key.description}
    + {/if} +
    + {#if safeFormatDate(key.createdAt, 'PP')} + Created {safeFormatDate(key.createdAt, 'PP')} + {/if} + {#if key.lastUsedAt && safeFormatRelative(key.lastUsedAt)} + · Last used {safeFormatRelative(key.lastUsedAt)} + {:else} + · Never used + {/if} +
    +
    + deleteApiKey(key.id, key.name)} + /> +
  • + {/each} +
+ {/if} +
+
+
+ + +
+ + +
+

Account info

+
+
+
+
Username
+
@{currentUser.username}
+
+
+
Account type
+
{isOidcUser ? 'Single sign-on' : 'Local'}
+
+ {#if safeFormatDate(currentUser.createdAt, 'PP')} +
+
Member since
+
{safeFormatDate(currentUser.createdAt, 'PP')}
+
+ {/if} +
+
Last login
+
+ {safeFormatRelative(currentUser.lastLogin) ?? 'Never'} +
+
+
+
+ + + +
+

Roles & access

+

Your assigned roles

+
+
+ {#if currentUser.roleAssignments && currentUser.roleAssignments.length > 0} +
    + {#each currentUser.roleAssignments as ra (`${ra.roleId}-${ra.environmentId ?? 'global'}`)} +
  • +
    +
    {prettyRoleName(ra.roleId)}
    +
    + {ra.environmentId ? `Environment: ${ra.environmentId}` : 'Global scope'} + {#if ra.source === 'oidc'} + · via SSO + {/if} +
    +
    +
  • + {/each} +
+ {:else} +

No roles assigned.

+ {/if} + + {#if currentUser.permissionsByEnv} + {@const envCount = Object.keys(currentUser.permissionsByEnv).length} + {@const globalCount = currentUser.permissionsByEnv[GLOBAL_SCOPE]?.length ?? 0} +

+ {globalCount} global permission{globalCount === 1 ? '' : 's'} across {envCount} environment{envCount === 1 + ? '' + : 's'}. +

+ {/if} +
+
+ + {#if !autoLoginEnabled} + +
+
+ +

Danger zone

+
+

Session-level actions that affect every device

+
+
+
+
Sign out other sessions
+

+ Revokes every active session except this one. Useful if you forgot to log out somewhere. +

+ +
+ + + +
+
Log out
+

Sign out of this device.

+
+ + +
+
+
+ {/if} +
+
+ {:else} +
Loading account…
+ {/if} +
diff --git a/frontend/src/routes/(app)/containers/components/container-stats-cell.svelte b/frontend/src/routes/(app)/containers/components/container-stats-cell.svelte index da38d7f5c7..a57c176417 100644 --- a/frontend/src/routes/(app)/containers/components/container-stats-cell.svelte +++ b/frontend/src/routes/(app)/containers/components/container-stats-cell.svelte @@ -1,5 +1,11 @@ + + +{#snippet ring()} + +{/snippet} + {#if stopped}
{m.common_na()}
{:else if loading}
-
+
{:else if type === 'memory' && memoryFormatted}
- {#if memoryPercent !== undefined} - - {/if} - + {@render ring()} + {memoryFormatted}
{:else if type === 'cpu' && value !== undefined}
- - + {@render ring()} + {value.toFixed(1)}%
diff --git a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte index fb678971e2..22143addfc 100644 --- a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte +++ b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte @@ -633,14 +633,14 @@ } -
+
-
-
+
+

{m.dashboard_title()}

-

{heroGreeting}

+

{heroGreeting}

-
-

{m.common_overview()}

+
+

{m.common_overview()}

{#await environmentBoardStatePromise} -
+
{#each [1, 2, 3, 4] as tile (tile)} -
+
- - + +
{/each}
{:then boardState} {@const summary = boardState.summary} -
-
+
+
{m.environments_title()}
-
{summary.totalEnvironments}
-
{formatEnvironmentOverviewLabel(summary)}
+
{summary.totalEnvironments}
+
{formatEnvironmentOverviewLabel(summary)}
-
+
{m.containers_title()}
-
{summary.totalContainers}
-
{formatContainerOverviewLabel(summary)}
+
{summary.totalContainers}
+
{formatContainerOverviewLabel(summary)}
-
+
{m.images_title()}
-
{summary.totalImages}
-
{formatImageOverviewLabel(summary)}
+
{summary.totalImages}
+
{formatImageOverviewLabel(summary)}
-
+
{m.dashboard_all_storage_title()}
-
{bytes.format(summary.totalImageSize)}
-
{formatStorageOverviewLabel(summary)}
+
{bytes.format(summary.totalImageSize)}
+
{formatStorageOverviewLabel(summary)}
{/await}
-
-

{m.dashboard_all_environment_board_title()}

+
+

{m.dashboard_all_environment_board_title()}

{#if environmentCards.length === 0} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35354e36d5..a300217676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,12 @@ importers: '@codemirror/view': specifier: ^6.43.0 version: 6.43.0 + '@fontsource-variable/geist-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/montserrat': + specifier: ^5.2.8 + version: 5.2.8 '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 @@ -594,6 +600,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/geist-mono@5.2.8': + resolution: {integrity: sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==} + + '@fontsource-variable/montserrat@5.2.8': + resolution: {integrity: sha512-d8wykq7GCKhEOlLwEuQIOK3w3qsXNxwDlH0meru4AZZzQ4/+rZyvHWKL6BBtHdmbovSHEEQDkwkD8qYUKlcFtQ==} + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -3372,6 +3384,10 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/geist-mono@5.2.8': {} + + '@fontsource-variable/montserrat@5.2.8': {} + '@hapi/hoek@9.3.0': optional: true From 222a199c9e12740c007a84261d0bae8f5c6cf785 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 28 May 2026 00:22:23 -0500 Subject: [PATCH 13/40] chore: run as root initially --- docker/Dockerfile-agent | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/Dockerfile-agent b/docker/Dockerfile-agent index 8cff7b4e5c..6e561bb79e 100644 --- a/docker/Dockerfile-agent +++ b/docker/Dockerfile-agent @@ -51,6 +51,7 @@ ARG TARGETARCH WORKDIR /app +USER 0:0 COPY --from=agent-builder --chown=65532:65532 /out/rootfs/app/ /app/ COPY --from=agent-builder --chown=65532:65532 /out/arcane-agent ./arcane-agent From d275ac019a3cb6faf991bbbb6eb559644dc95677 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 28 May 2026 00:57:16 -0500 Subject: [PATCH 14/40] refactor: updated tab bar design --- frontend/src/lib/components/tab-bar/tab-bar.svelte | 2 +- frontend/src/lib/components/ui/tabs/tabs-list.svelte | 2 +- frontend/src/lib/components/ui/tabs/tabs-trigger.svelte | 2 +- frontend/src/routes/(app)/dashboard/+page.svelte | 2 +- frontend/src/routes/(app)/environments/[id]/+page.svelte | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/tab-bar/tab-bar.svelte b/frontend/src/lib/components/tab-bar/tab-bar.svelte index 54100d314e..5fbe1f6d0c 100644 --- a/frontend/src/lib/components/tab-bar/tab-bar.svelte +++ b/frontend/src/lib/components/tab-bar/tab-bar.svelte @@ -13,7 +13,7 @@ let { items, onValueChange, class: className }: Props = $props(); - + {#each items as item} {@const IconComponent = item.icon}
- +
{#if activeView === 'all'} diff --git a/frontend/src/routes/(app)/environments/[id]/+page.svelte b/frontend/src/routes/(app)/environments/[id]/+page.svelte index 975da32926..ce9648f3c6 100644 --- a/frontend/src/routes/(app)/environments/[id]/+page.svelte +++ b/frontend/src/routes/(app)/environments/[id]/+page.svelte @@ -619,7 +619,7 @@
- +
From d261dcf839d183fe21388c6afe2e919457849e97 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 28 May 2026 18:46:27 -0500 Subject: [PATCH 15/40] fix: decouple context for activities (#2754) --- backend/api/api.go | 23 ++-- backend/api/handlers/containers.go | 53 ++++++---- backend/api/handlers/helpers.go | 19 ++++ backend/api/handlers/image_updates.go | 17 ++- backend/api/handlers/images.go | 27 +++-- backend/api/handlers/networks.go | 19 ++-- backend/api/handlers/projects.go | 100 ++++++++++-------- backend/api/handlers/system.go | 19 ++-- backend/api/handlers/updater.go | 11 +- backend/api/handlers/volumes.go | 59 +++++++---- backend/api/handlers/vulnerabilities.go | 8 +- backend/api/webhooks_trigger.go | 6 +- backend/internal/bootstrap/bootstrap.go | 2 + .../internal/bootstrap/router_bootstrap.go | 7 +- backend/internal/services/activity_service.go | 5 +- .../internal/services/image_update_service.go | 3 +- .../services/image_update_service_test.go | 47 ++++++++ backend/internal/services/system_service.go | 6 +- backend/internal/services/updater_service.go | 2 +- .../services/vulnerability_service.go | 4 +- backend/pkg/libarcane/activity/handler.go | 2 +- backend/pkg/libarcane/activity/writer.go | 5 +- backend/pkg/libarcane/activity/writer_test.go | 54 ++++++++++ backend/pkg/projects/cmds.go | 5 + backend/pkg/projects/cmds_test.go | 11 ++ backend/pkg/utils/activity_context.go | 73 +++++++++++++ backend/pkg/utils/activity_context_test.go | 82 ++++++++++++++ 27 files changed, 519 insertions(+), 150 deletions(-) create mode 100644 backend/pkg/libarcane/activity/writer_test.go create mode 100644 backend/pkg/utils/activity_context.go create mode 100644 backend/pkg/utils/activity_context_test.go diff --git a/backend/api/api.go b/backend/api/api.go index 40f83d3ec4..131fbdc842 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "reflect" "strings" @@ -138,6 +139,7 @@ func capitalizeFirst(s string) string { // Services holds all service dependencies needed by Huma handlers. type Services struct { + AppContext context.Context User *services.UserService Auth *services.AuthService Oidc *services.OidcService @@ -347,9 +349,11 @@ func registerHandlers(api huma.API, svc *Services) { var vulnerabilitySvc *services.VulnerabilityService var dashboardSvc *services.DashboardService var roleSvc *services.RoleService + var appCtx context.Context var cfg *config.Config if svc != nil { + appCtx = svc.AppContext userSvc = svc.User authSvc = svc.Auth oidcSvc = svc.Oidc @@ -388,13 +392,14 @@ func registerHandlers(api huma.API, svc *Services) { roleSvc = svc.Role cfg = svc.Config } + handlerAppCtx := handlers.NewActivityAppContext(appCtx) handlers.RegisterHealth(api) handlers.RegisterAuth(api, userSvc, authSvc, oidcSvc) handlers.RegisterApiKeys(api, apiKeySvc) handlers.RegisterRoles(api, roleSvc) handlers.RegisterAppImages(api, appImagesSvc) handlers.RegisterUsers(api, userSvc, authSvc) - handlers.RegisterProjects(api, projectSvc, activitySvc) + handlers.RegisterProjects(api, projectSvc, activitySvc, handlerAppCtx) handlers.RegisterVersion(api, versionSvc) handlers.RegisterEvents(api, eventSvc, apiKeySvc) handlers.RegisterActivities(api, activitySvc, environmentSvc) @@ -402,23 +407,23 @@ func registerHandlers(api huma.API, svc *Services) { handlers.RegisterEnvironments(api, environmentSvc, settingsSvc, apiKeySvc, eventSvc, cfg) handlers.RegisterContainerRegistries(api, containerRegistrySvc, environmentSvc) handlers.RegisterTemplates(api, templateSvc, environmentSvc) - handlers.RegisterImages(api, dockerSvc, imageSvc, imageUpdateSvc, settingsSvc, buildSvc, activitySvc) + handlers.RegisterImages(api, dockerSvc, imageSvc, imageUpdateSvc, settingsSvc, buildSvc, activitySvc, handlerAppCtx) handlers.RegisterBuildWorkspaces(api, buildWorkspaceSvc) - handlers.RegisterImageUpdates(api, imageUpdateSvc, imageSvc) + handlers.RegisterImageUpdates(api, imageUpdateSvc, imageSvc, handlerAppCtx) handlers.RegisterSettings(api, settingsSvc, settingsSearchSvc, environmentSvc, cfg) handlers.RegisterJobSchedules(api, jobScheduleSvc, environmentSvc) - handlers.RegisterVolumes(api, dockerSvc, volumeSvc, activitySvc) - handlers.RegisterContainers(api, containerSvc, dockerSvc, settingsSvc, activitySvc) + handlers.RegisterVolumes(api, dockerSvc, volumeSvc, activitySvc, handlerAppCtx) + handlers.RegisterContainers(api, containerSvc, dockerSvc, settingsSvc, activitySvc, handlerAppCtx) handlers.RegisterPorts(api, portSvc) - handlers.RegisterNetworks(api, networkSvc, dockerSvc, activitySvc) + handlers.RegisterNetworks(api, networkSvc, dockerSvc, activitySvc, handlerAppCtx) handlers.RegisterSwarm(api, swarmSvc, environmentSvc, eventSvc, cfg) handlers.RegisterNotifications(api, notificationSvc, cfg) - handlers.RegisterUpdater(api, updaterSvc) + handlers.RegisterUpdater(api, updaterSvc, handlerAppCtx) handlers.RegisterCustomize(api, customizeSearchSvc) - handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg, activitySvc) + handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg, activitySvc, handlerAppCtx) handlers.RegisterGitRepositories(api, gitRepositorySvc) handlers.RegisterGitOpsSyncs(api, gitOpsSyncSvc) handlers.RegisterWebhooks(api, webhookSvc) - handlers.RegisterVulnerability(api, vulnerabilitySvc) + handlers.RegisterVulnerability(api, vulnerabilitySvc, handlerAppCtx) handlers.RegisterDashboard(api, dashboardSvc) } diff --git a/backend/api/handlers/containers.go b/backend/api/handlers/containers.go index a7a80d4e8f..ead17960c7 100644 --- a/backend/api/handlers/containers.go +++ b/backend/api/handlers/containers.go @@ -30,6 +30,7 @@ type ContainerHandler struct { dockerService *services.DockerClientService settingsService *services.SettingsService activityService *services.ActivityService + appCtx context.Context } // Paginated response @@ -142,12 +143,13 @@ type SetAutoUpdateOutput struct { Body ContainerActionResponse } -func RegisterContainers(api huma.API, containerSvc *services.ContainerService, dockerSvc *services.DockerClientService, settingsSvc *services.SettingsService, activitySvc *services.ActivityService) { +func RegisterContainers(api huma.API, containerSvc *services.ContainerService, dockerSvc *services.DockerClientService, settingsSvc *services.SettingsService, activitySvc *services.ActivityService, appCtx ActivityAppContext) { h := &ContainerHandler{ containerService: containerSvc, dockerService: dockerSvc, settingsService: settingsSvc, activityService: activitySvc, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -622,12 +624,13 @@ func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerA return nil, huma.Error401Unauthorized("not authenticated") } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID}) - if err := h.containerService.StartContainer(ctx, input.ContainerID, *user); err != nil { - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container started", err) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID}) + if err := h.containerService.StartContainer(runtimeCtx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container started", err) return nil, huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) } - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container started", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container started", nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ @@ -647,12 +650,13 @@ func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerAc return nil, huma.Error401Unauthorized("not authenticated") } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID}) - if err := h.containerService.StopContainer(ctx, input.ContainerID, *user); err != nil { - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container stopped", err) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID}) + if err := h.containerService.StopContainer(runtimeCtx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container stopped", err) return nil, huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) } - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container stopped", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container stopped", nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ @@ -672,12 +676,13 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe return nil, huma.Error401Unauthorized("not authenticated") } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID}) - if err := h.containerService.RestartContainer(ctx, input.ContainerID, *user); err != nil { - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container restarted", err) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID}) + if err := h.containerService.RestartContainer(runtimeCtx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container restarted", err) return nil, huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) } - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container restarted", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container restarted", nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ @@ -697,20 +702,21 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain return nil, huma.Error401Unauthorized("not authenticated") } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID}) - activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Redeploying container") - redeployCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Redeploying container") + redeployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) newContainerID, err := h.containerService.RedeployContainer(redeployCtx, input.ContainerID, *user) if err != nil { activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container redeploy failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container redeploy failed", err) return nil, huma.Error500InternalServerError((&common.ContainerRedeployError{Err: err}).Error()) } activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container redeployed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container redeployed", nil) // Fetch full container details to return (consistent with other endpoints) - details, inspectErr := h.containerService.GetContainerDetails(ctx, newContainerID) + details, inspectErr := h.containerService.GetContainerDetails(runtimeCtx, newContainerID) if inspectErr == nil { details.ActivityID = utils.StringPtrFromTrimmed(activityID) @@ -745,12 +751,13 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon return nil, huma.Error401Unauthorized("not authenticated") } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes}) - if err := h.containerService.DeleteContainer(ctx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil { - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container deleted", err) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes}) + if err := h.containerService.DeleteContainer(runtimeCtx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container deleted", err) return nil, huma.Error500InternalServerError((&common.ContainerDeleteError{Err: err}).Error()) } - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Container deleted", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container deleted", nil) return &DeleteContainerOutput{ Body: ContainerActionResponse{ diff --git a/backend/api/handlers/helpers.go b/backend/api/handlers/helpers.go index f7f37d1070..20ad2937e3 100644 --- a/backend/api/handlers/helpers.go +++ b/backend/api/handlers/helpers.go @@ -11,6 +11,25 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/remenv" ) +// ActivityAppContext carries the app lifecycle context through handler registration. +type ActivityAppContext struct { + ctx context.Context +} + +// NewActivityAppContext wraps the app lifecycle context for handler constructors. +func NewActivityAppContext(ctx context.Context) ActivityAppContext { + return ActivityAppContext{ctx: ctx} +} + +// ContextInternal returns the wrapped app lifecycle context. +func (c ActivityAppContext) ContextInternal() context.Context { + return c.contextInternal() +} + +func (c ActivityAppContext) contextInternal() context.Context { + return c.ctx +} + // buildPaginationParamsInternal converts query parameters to pagination.QueryParams. // A limit of -1 means "show all items" (no pagination). func buildPaginationParamsInternal(start, limit int, sortCol, sortDir, search string) pagination.QueryParams { diff --git a/backend/api/handlers/image_updates.go b/backend/api/handlers/image_updates.go index 6ec2867734..fe859a5918 100644 --- a/backend/api/handlers/image_updates.go +++ b/backend/api/handlers/image_updates.go @@ -10,6 +10,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" imagetypes "github.com/getarcaneapp/arcane/types/image" "github.com/getarcaneapp/arcane/types/imageupdate" @@ -18,6 +19,7 @@ import ( type ImageUpdateHandler struct { imageUpdateService *services.ImageUpdateService imageService *services.ImageService + appCtx context.Context } type CheckImageUpdateInput struct { @@ -74,10 +76,11 @@ type GetUpdateSummaryOutput struct { } // RegisterImageUpdates registers image update endpoints. -func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateService, imageSvc *services.ImageService) { +func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateService, imageSvc *services.ImageService, appCtx ActivityAppContext) { h := &ImageUpdateHandler{ imageUpdateService: imageUpdateSvc, imageService: imageSvc, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -156,7 +159,8 @@ func (h *ImageUpdateHandler) CheckImageUpdate(ctx context.Context, input *CheckI return nil, huma.Error400BadRequest((&common.ImageRefRequiredError{}).Error()) } - result, err := h.imageUpdateService.CheckImageUpdate(ctx, input.ImageRef) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.imageUpdateService.CheckImageUpdate(runtimeCtx, input.ImageRef) if err != nil { return nil, huma.Error500InternalServerError((&common.ImageUpdateCheckError{Err: err}).Error()) } @@ -174,7 +178,8 @@ func (h *ImageUpdateHandler) CheckImageUpdateByID(ctx context.Context, input *Ch return nil, huma.Error400BadRequest((&common.ImageIDRequiredError{}).Error()) } - result, err := h.imageUpdateService.CheckImageUpdateByID(ctx, input.ImageID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.imageUpdateService.CheckImageUpdateByID(runtimeCtx, input.ImageID) if err != nil { return nil, huma.Error500InternalServerError((&common.ImageUpdateCheckError{Err: err}).Error()) } @@ -198,7 +203,8 @@ func (h *ImageUpdateHandler) CheckMultipleImages(ctx context.Context, input *Che }, nil } - results, err := h.imageUpdateService.CheckMultipleImages(ctx, input.Body.ImageRefs, input.Body.Credentials) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + results, err := h.imageUpdateService.CheckMultipleImages(runtimeCtx, input.Body.ImageRefs, input.Body.Credentials) if err != nil { return nil, huma.Error500InternalServerError((&common.BatchImageUpdateCheckError{Err: err}).Error()) } @@ -212,7 +218,8 @@ func (h *ImageUpdateHandler) CheckMultipleImages(ctx context.Context, input *Che } func (h *ImageUpdateHandler) CheckAllImages(ctx context.Context, input *CheckAllImagesInput) (*CheckAllImagesOutput, error) { - results, err := h.imageUpdateService.CheckAllImages(ctx, 0, input.Body.Credentials) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + results, err := h.imageUpdateService.CheckAllImages(runtimeCtx, 0, input.Body.Credentials) if err != nil { return nil, huma.Error500InternalServerError((&common.AllImageUpdateCheckError{Err: err}).Error()) } diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index c3b377c1b7..63a85fcae9 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -16,6 +16,7 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/authz" activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/image" "github.com/getarcaneapp/arcane/types/system" @@ -30,6 +31,7 @@ type ImageHandler struct { settingsService *services.SettingsService buildService *services.BuildService activityService *services.ActivityService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -153,7 +155,7 @@ type UploadImageOutput struct { } // RegisterImages registers image management routes using Huma. -func RegisterImages(api huma.API, dockerService *services.DockerClientService, imageService *services.ImageService, imageUpdateService *services.ImageUpdateService, settingsService *services.SettingsService, buildService *services.BuildService, activityService *services.ActivityService) { +func RegisterImages(api huma.API, dockerService *services.DockerClientService, imageService *services.ImageService, imageUpdateService *services.ImageUpdateService, settingsService *services.SettingsService, buildService *services.BuildService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &ImageHandler{ dockerService: dockerService, imageService: imageService, @@ -161,6 +163,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i settingsService: settingsService, buildService: buildService, activityService: activityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -448,9 +451,10 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() activityID := activitylib.StartHandlerActivityForUser( - humaCtx.Context(), + runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeImagePull, @@ -467,15 +471,15 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h f.Flush() } - writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Pulling image") - if err := h.imageService.PullImage(humaCtx.Context(), fullImageName, writer, *user, credentials); err != nil { + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Pulling image") + if err := h.imageService.PullImage(runtimeCtx, fullImageName, writer, *user, credentials); err != nil { activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image pull failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image pull failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) return } activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image pull completed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image pull completed", nil) }, }, nil } @@ -502,13 +506,14 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) ( humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() resourceName := strings.Join(input.Body.Tags, ", ") if strings.TrimSpace(resourceName) == "" { resourceName = input.Body.ContextDir } activityID := activitylib.StartHandlerActivityForUser( - humaCtx.Context(), + runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeImageBuild, @@ -525,15 +530,15 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) ( f.Flush() } - writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Building image") - if _, err := h.buildService.BuildImage(humaCtx.Context(), input.EnvironmentID, input.Body, writer, "", user); err != nil { + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Building image") + if _, err := h.buildService.BuildImage(runtimeCtx, input.EnvironmentID, input.Body, writer, "", user); err != nil { activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image build failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image build failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) return } activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Image build completed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image build completed", nil) }, }, nil } diff --git a/backend/api/handlers/networks.go b/backend/api/handlers/networks.go index 81a8fc3481..bd7039d427 100644 --- a/backend/api/handlers/networks.go +++ b/backend/api/handlers/networks.go @@ -27,6 +27,7 @@ type NetworkHandler struct { networkService *services.NetworkService dockerService *services.DockerClientService activityService *services.ActivityService + appCtx context.Context } type NetworkPaginatedResponse struct { @@ -137,11 +138,12 @@ type PruneNetworksOutput struct { } // RegisterNetworks registers network endpoints. -func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerSvc *services.DockerClientService, activitySvc *services.ActivityService) { +func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerSvc *services.DockerClientService, activitySvc *services.ActivityService, appCtx ActivityAppContext) { h := &NetworkHandler{ networkService: networkSvc, dockerService: dockerSvc, activityService: activitySvc, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -285,7 +287,8 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork dockerOptions := input.Body.Options.ToDockerCreateOptions() var response *dockernetwork.CreateResponse - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "network", @@ -301,7 +304,7 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork }, }, func() error { var createErr error - response, createErr = h.networkService.CreateNetwork(ctx, input.Body.Name, dockerOptions, *user) + response, createErr = h.networkService.CreateNetwork(runtimeCtx, input.Body.Name, dockerOptions, *user) return createErr }) if err != nil { @@ -430,7 +433,8 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork return nil, huma.Error401Unauthorized("not authenticated") } - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "network", @@ -444,7 +448,7 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork "action": "remove_network", }, }, func() error { - return h.networkService.RemoveNetwork(ctx, input.NetworkID, *user) + return h.networkService.RemoveNetwork(runtimeCtx, input.NetworkID, *user) }) if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkRemovalError{Err: err}).Error()) @@ -460,7 +464,8 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworksInput) (*PruneNetworksOutput, error) { var report *dockernetwork.PruneReport - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "network", @@ -470,7 +475,7 @@ func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworks Metadata: models.JSON{"action": "prune_networks"}, }, func() error { var pruneErr error - report, pruneErr = h.networkService.PruneNetworks(ctx) + report, pruneErr = h.networkService.PruneNetworks(runtimeCtx) return pruneErr }) if err != nil { diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index 8ca96e924a..e2f1196874 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -28,6 +28,7 @@ import ( type ProjectHandler struct { projectService *services.ProjectService activityService *services.ActivityService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -206,10 +207,11 @@ type PullProgressEvent struct { // RegisterProjects registers project management routes using Huma. // Note: WebSocket and streaming endpoints remain as Gin handlers. -func RegisterProjects(api huma.API, projectService *services.ProjectService, activityService *services.ActivityService) { +func RegisterProjects(api huma.API, projectService *services.ProjectService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &ProjectHandler{ projectService: projectService, activityService: activityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -598,9 +600,10 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() activityID := activitylib.StartHandlerActivityForUser( - humaCtx.Context(), + runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDeploy, @@ -617,16 +620,16 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject f.Flush() } - writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Deploying project") + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Deploying project") _, _ = writer.Write([]byte(`{"type":"deploy","phase":"begin"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } - deployCtx := context.WithValue(humaCtx.Context(), projects.ProgressWriterKey{}, writer) + deployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, writer) if err := h.projectService.DeployProject(deployCtx, input.ProjectID, *user, input.Body); err != nil { activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project deployment failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project deployment failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -638,7 +641,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject if f, ok := writer.(http.Flusher); ok { f.Flush() } - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project deployment completed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project deployment completed", nil) }, }, nil } @@ -654,12 +657,13 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID}) - activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Stopping project") - downCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Stopping project") + downCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.DownProject(downCtx, input.ProjectID, *user); err != nil { activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project stopped", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project stopped", err) var archivedErr *common.ProjectArchivedError if errors.As(err, &archivedErr) { return nil, huma.Error400BadRequest((&common.ProjectDownError{Err: err}).Error()) @@ -667,7 +671,7 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu return nil, huma.Error500InternalServerError((&common.ProjectDownError{Err: err}).Error()) } activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project stopped", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project stopped", nil) return &DownProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ @@ -692,7 +696,8 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject } var proj *models.Project - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "project", @@ -705,7 +710,7 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject Metadata: models.JSON{"action": "create_project"}, }, func() error { var createErr error - proj, createErr = h.projectService.CreateProject(ctx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + proj, createErr = h.projectService.CreateProject(runtimeCtx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) return createErr }) if err != nil { @@ -859,8 +864,9 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) activityID := activitylib.StartHandlerActivityForUser( - ctx, + runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRedeploy, @@ -872,11 +878,11 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro "Project redeploy started", models.JSON{"projectID": input.ProjectID}, ) - activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Redeploying project") - redeployCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Redeploying project") + redeployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.RedeployProject(redeployCtx, input.ProjectID, *user); err != nil { activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project redeploy failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project redeploy failed", err) var archivedErr *common.ProjectArchivedError if errors.As(err, &archivedErr) { return nil, huma.Error400BadRequest(err.Error()) @@ -884,7 +890,7 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro return nil, huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error()) } activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project redeploy completed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project redeploy completed", nil) return &RedeployProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ @@ -922,16 +928,17 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje "projectID", input.ProjectID) } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes}) - activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Destroying project") - destroyCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Destroying project") + destroyCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.DestroyProject(destroyCtx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil { activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project destroyed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project destroyed", err) return nil, huma.Error500InternalServerError((&common.ProjectDestroyError{Err: err}).Error()) } activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project destroyed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project destroyed", nil) return &DestroyProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ @@ -959,7 +966,8 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "project", @@ -971,14 +979,14 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject SuccessMessage: "Project updated successfully", Metadata: models.JSON{"action": "update_project", "projectID": input.ProjectID}, }, func() error { - _, updateErr := h.projectService.UpdateProject(ctx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + _, updateErr := h.projectService.UpdateProject(runtimeCtx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) return updateErr }) if err != nil { return nil, huma.Error400BadRequest((&common.ProjectUpdateError{Err: err}).Error()) } - details, err := h.projectService.GetProjectDetails(ctx, input.ProjectID, project.AllDetails()) + details, err := h.projectService.GetProjectDetails(runtimeCtx, input.ProjectID, project.AllDetails()) if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectDetailsError{Err: err}).Error()) } @@ -1007,7 +1015,8 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "project", @@ -1023,13 +1032,13 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update "relativePath": input.Body.RelativePath, }, }, func() error { - return h.projectService.UpdateProjectIncludeFile(ctx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user) + return h.projectService.UpdateProjectIncludeFile(runtimeCtx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user) }) if err != nil { return nil, huma.Error400BadRequest((&common.ProjectUpdateError{Err: err}).Error()) } - details, err := h.projectService.GetProjectDetails(ctx, input.ProjectID, project.AllDetails()) + details, err := h.projectService.GetProjectDetails(runtimeCtx, input.ProjectID, project.AllDetails()) if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectDetailsError{Err: err}).Error()) } @@ -1058,12 +1067,13 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - activityID := activitylib.StartHandlerActivityForUser(ctx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID}) - activityWriter := activitylib.NewWriter(ctx, h.activityService, activityID, io.Discard, "Restarting project") - restartCtx := context.WithValue(ctx, projects.ProgressWriterKey{}, activityWriter) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Restarting project") + restartCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.RestartProject(restartCtx, input.ProjectID, *user); err != nil { activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project restarted", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project restarted", err) var archivedErr *common.ProjectArchivedError if errors.As(err, &archivedErr) { return nil, huma.Error400BadRequest(err.Error()) @@ -1071,7 +1081,7 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje return nil, huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error()) } activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(ctx, h.activityService, activityID, "Project restarted", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project restarted", nil) return &RestartProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ @@ -1162,9 +1172,10 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() activityID := activitylib.StartHandlerActivityForUser( - humaCtx.Context(), + runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectPull, @@ -1178,15 +1189,15 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje ) activitylib.WriteStartedLine(rawWriter, activityID) - writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Pulling project images") + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Pulling project images") _, _ = writer.Write([]byte(`{"status":"starting project image pull"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } - if err := h.projectService.PullProjectImages(humaCtx.Context(), input.ProjectID, writer, *user, nil); err != nil { + if err := h.projectService.PullProjectImages(runtimeCtx, input.ProjectID, writer, *user, nil); err != nil { activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image pull failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image pull failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -1198,7 +1209,7 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje if f, ok := writer.(http.Flusher); ok { f.Flush() } - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image pull completed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image pull completed", nil) }, }, nil } @@ -1233,9 +1244,10 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro humaCtx.SetHeader("Connection", "keep-alive") humaCtx.SetHeader("X-Accel-Buffering", "no") + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() activityID := activitylib.StartHandlerActivityForUser( - humaCtx.Context(), + runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectBuild, @@ -1249,15 +1261,15 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro ) activitylib.WriteStartedLine(rawWriter, activityID) - writer := activitylib.NewWriter(humaCtx.Context(), h.activityService, activityID, rawWriter, "Building project images") + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Building project images") _, _ = writer.Write([]byte(`{"type":"build","phase":"begin"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } - if err := h.projectService.BuildProjectServices(humaCtx.Context(), input.ProjectID, options, writer, user); err != nil { + if err := h.projectService.BuildProjectServices(runtimeCtx, input.ProjectID, options, writer, user); err != nil { activitylib.FlushWriter(writer) - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image build failed", err) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image build failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -1269,7 +1281,7 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro if f, ok := writer.(http.Flusher); ok { f.Flush() } - activitylib.CompleteHandlerActivity(humaCtx.Context(), h.activityService, activityID, "Project image build completed", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image build completed", nil) }, }, nil } diff --git a/backend/api/handlers/system.go b/backend/api/handlers/system.go index de9a9eb18c..a72ca48767 100644 --- a/backend/api/handlers/system.go +++ b/backend/api/handlers/system.go @@ -13,6 +13,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" "github.com/getarcaneapp/arcane/types/dockerinfo" @@ -28,6 +29,7 @@ type SystemHandler struct { upgradeService *services.SystemUpgradeService activityService *services.ActivityService cfg *config.Config + appCtx context.Context } // --- Input/Output Types --- @@ -115,13 +117,14 @@ type TriggerUpgradeOutput struct { // RegisterSystem registers system management endpoints using Huma. // Note: WebSocket endpoints (stats) remain in the Gin handler. -func RegisterSystem(api huma.API, dockerService *services.DockerClientService, systemService *services.SystemService, upgradeService *services.SystemUpgradeService, cfg *config.Config, activityService *services.ActivityService) { +func RegisterSystem(api huma.API, dockerService *services.DockerClientService, systemService *services.SystemService, upgradeService *services.SystemUpgradeService, cfg *config.Config, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &SystemHandler{ dockerService: dockerService, systemService: systemService, upgradeService: upgradeService, activityService: activityService, cfg: cfg, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -376,9 +379,10 @@ func (h *SystemHandler) PruneAll(ctx context.Context, input *PruneAllInput) (*Pr "networks", input.Body.Networks, "build_cache", input.Body.BuildCache) - result := h.systemService.StartPruneAll(ctx, input.EnvironmentID, input.Body) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result := h.systemService.StartPruneAll(runtimeCtx, input.EnvironmentID, input.Body) - slog.InfoContext(ctx, "System prune background activity started", "activityId", result.ActivityID) + slog.InfoContext(runtimeCtx, "System prune background activity started", "activityId", result.ActivityID) return &PruneAllOutput{ Body: base.ApiResponse[system.PruneAllResult]{ @@ -394,7 +398,8 @@ func (h *SystemHandler) StartAllContainers(ctx context.Context, input *StartAllC return nil, huma.Error500InternalServerError("service not available") } - result, err := h.systemService.StartAllContainers(ctx, input.EnvironmentID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.systemService.StartAllContainers(runtimeCtx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartAllError{Err: err}).Error()) } @@ -413,7 +418,8 @@ func (h *SystemHandler) StartAllStoppedContainers(ctx context.Context, input *St return nil, huma.Error500InternalServerError("service not available") } - result, err := h.systemService.StartAllStoppedContainers(ctx, input.EnvironmentID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.systemService.StartAllStoppedContainers(runtimeCtx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartStoppedError{Err: err}).Error()) } @@ -432,7 +438,8 @@ func (h *SystemHandler) StopAllContainers(ctx context.Context, input *StopAllCon return nil, huma.Error500InternalServerError("service not available") } - result, err := h.systemService.StopAllContainers(ctx, input.EnvironmentID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.systemService.StopAllContainers(runtimeCtx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStopAllError{Err: err}).Error()) } diff --git a/backend/api/handlers/updater.go b/backend/api/handlers/updater.go index c168bf2f7c..5621dd6f81 100644 --- a/backend/api/handlers/updater.go +++ b/backend/api/handlers/updater.go @@ -10,6 +10,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/updater" ) @@ -17,6 +18,7 @@ import ( // UpdaterHandler provides Huma-based updater management endpoints. type UpdaterHandler struct { updaterService *services.UpdaterService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -57,9 +59,10 @@ type GetUpdaterHistoryOutput struct { } // RegisterUpdater registers updater management routes using Huma. -func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { +func RegisterUpdater(api huma.API, updaterService *services.UpdaterService, appCtx ActivityAppContext) { h := &UpdaterHandler{ updaterService: updaterService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -130,7 +133,8 @@ func (h *UpdaterHandler) RunUpdater(ctx context.Context, input *RunUpdaterInput) dryRun = input.Body.DryRun } - out, err := h.updaterService.ApplyPending(ctx, dryRun) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + out, err := h.updaterService.ApplyPending(runtimeCtx, dryRun) if err != nil { return nil, huma.Error500InternalServerError((&common.UpdaterRunError{Err: err}).Error()) } @@ -189,7 +193,8 @@ func (h *UpdaterHandler) UpdateContainer(ctx context.Context, input *UpdateConta return nil, huma.Error500InternalServerError("service not available") } - out, err := h.updaterService.UpdateSingleContainer(ctx, input.ContainerID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + out, err := h.updaterService.UpdateSingleContainer(runtimeCtx, input.ContainerID) if err != nil { return nil, huma.Error500InternalServerError((&common.UpdaterRunError{Err: err}).Error()) } diff --git a/backend/api/handlers/volumes.go b/backend/api/handlers/volumes.go index 156d3a127d..8ace14d2a5 100644 --- a/backend/api/handlers/volumes.go +++ b/backend/api/handlers/volumes.go @@ -27,6 +27,7 @@ type VolumeHandler struct { volumeService *services.VolumeService dockerService *services.DockerClientService activityService *services.ActivityService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -299,11 +300,12 @@ type UploadAndRestoreOutput struct { } // RegisterVolumes registers volume management routes using Huma. -func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService, activityService *services.ActivityService) { +func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &VolumeHandler{ volumeService: volumeService, dockerService: dockerService, activityService: activityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -749,7 +751,8 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp } var response *volumetypes.Volume - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -765,7 +768,7 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp }, }, func() error { var createErr error - response, createErr = h.volumeService.CreateVolume(ctx, options, *user) + response, createErr = h.volumeService.CreateVolume(runtimeCtx, options, *user) return createErr }) if err != nil { @@ -792,7 +795,8 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -807,7 +811,7 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp "force": input.Force, }, }, func() error { - return h.volumeService.DeleteVolume(ctx, input.VolumeName, input.Force, *user) + return h.volumeService.DeleteVolume(runtimeCtx, input.VolumeName, input.Force, *user) }) if err != nil { return nil, huma.Error500InternalServerError((&common.VolumeDeletionError{Err: err}).Error()) @@ -831,7 +835,8 @@ func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInp } var report *volumetypes.PruneReport - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -841,7 +846,7 @@ func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInp Metadata: models.JSON{"action": "prune_volumes"}, }, func() error { var pruneErr error - report, pruneErr = h.volumeService.PruneVolumes(ctx) + report, pruneErr = h.volumeService.PruneVolumes(runtimeCtx) return pruneErr }) if err != nil { @@ -1011,7 +1016,8 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) defer func() { _ = file.Close() }() user, _ := humamw.GetCurrentUserFromContext(ctx) - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1027,7 +1033,7 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) "filename": fileHeader.Filename, }, }, func() error { - return h.volumeService.UploadFile(ctx, input.VolumeName, input.Path, file, fileHeader.Filename, user) + return h.volumeService.UploadFile(runtimeCtx, input.VolumeName, input.Path, file, fileHeader.Filename, user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) @@ -1043,7 +1049,8 @@ func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirect return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1058,7 +1065,7 @@ func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirect "path": input.Path, }, }, func() error { - return h.volumeService.CreateDirectory(ctx, input.VolumeName, input.Path, user) + return h.volumeService.CreateDirectory(runtimeCtx, input.VolumeName, input.Path, user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) @@ -1074,7 +1081,8 @@ func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1089,7 +1097,7 @@ func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) "path": input.Path, }, }, func() error { - return h.volumeService.DeleteFile(ctx, input.VolumeName, input.Path, user) + return h.volumeService.DeleteFile(runtimeCtx, input.VolumeName, input.Path, user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) @@ -1163,7 +1171,8 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp } var backup *models.VolumeBackup - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1176,7 +1185,7 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp Metadata: models.JSON{"action": "create_volume_backup"}, }, func() error { var backupErr error - backup, backupErr = h.volumeService.CreateBackup(ctx, input.VolumeName, *user) + backup, backupErr = h.volumeService.CreateBackup(runtimeCtx, input.VolumeName, *user) return backupErr }) if err != nil { @@ -1200,7 +1209,8 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1215,7 +1225,7 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI "backupId": input.BackupID, }, }, func() error { - return h.volumeService.RestoreBackup(ctx, input.VolumeName, input.BackupID, *user) + return h.volumeService.RestoreBackup(runtimeCtx, input.VolumeName, input.BackupID, *user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) @@ -1242,7 +1252,8 @@ func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBa return nil, huma.Error400BadRequest("paths are required") } - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1258,7 +1269,7 @@ func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBa "paths": input.Body.Paths, }, }, func() error { - return h.volumeService.RestoreBackupFiles(ctx, input.VolumeName, input.BackupID, input.Body.Paths, *user) + return h.volumeService.RestoreBackupFiles(runtimeCtx, input.VolumeName, input.BackupID, input.Body.Paths, *user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) @@ -1317,7 +1328,8 @@ func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInp return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume_backup", @@ -1332,7 +1344,7 @@ func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInp "backupId": input.BackupID, }, }, func() error { - return h.volumeService.DeleteBackup(ctx, input.BackupID, user) + return h.volumeService.DeleteBackup(runtimeCtx, input.BackupID, user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) @@ -1390,7 +1402,8 @@ func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRe } defer func() { _ = file.Close() }() - activityID, err := activitylib.RunHandlerActivity(ctx, h.activityService, activitylib.HandlerOptions{ + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ EnvironmentID: input.EnvironmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", @@ -1405,7 +1418,7 @@ func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRe "filename": fileHeader.Filename, }, }, func() error { - return h.volumeService.UploadAndRestore(ctx, input.VolumeName, file, fileHeader.Filename, *user) + return h.volumeService.UploadAndRestore(runtimeCtx, input.VolumeName, file, fileHeader.Filename, *user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) diff --git a/backend/api/handlers/vulnerabilities.go b/backend/api/handlers/vulnerabilities.go index e555bf8277..65c7186f04 100644 --- a/backend/api/handlers/vulnerabilities.go +++ b/backend/api/handlers/vulnerabilities.go @@ -9,6 +9,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/vulnerability" ) @@ -16,6 +17,7 @@ import ( // VulnerabilityHandler provides Huma-based vulnerability scanning endpoints. type VulnerabilityHandler struct { vulnerabilityService *services.VulnerabilityService + appCtx context.Context } // --- Huma Input/Output Types --- @@ -122,9 +124,10 @@ type GetScannerStatusOutput struct { } // RegisterVulnerability registers vulnerability scanning routes using Huma. -func RegisterVulnerability(api huma.API, vulnerabilityService *services.VulnerabilityService) { +func RegisterVulnerability(api huma.API, vulnerabilityService *services.VulnerabilityService, appCtx ActivityAppContext) { h := &VulnerabilityHandler{ vulnerabilityService: vulnerabilityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -307,7 +310,8 @@ func (h *VulnerabilityHandler) ScanImage(ctx context.Context, input *ScanImageIn return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - result, err := h.vulnerabilityService.ScanImage(ctx, input.EnvironmentID, input.ImageID, *user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.vulnerabilityService.ScanImage(runtimeCtx, input.EnvironmentID, input.ImageID, *user) if err != nil { return nil, huma.Error500InternalServerError((&common.VulnerabilityScanError{Err: err}).Error()) } diff --git a/backend/api/webhooks_trigger.go b/backend/api/webhooks_trigger.go index 7cd72f693d..b24b1a787c 100644 --- a/backend/api/webhooks_trigger.go +++ b/backend/api/webhooks_trigger.go @@ -4,21 +4,23 @@ import ( "errors" "net/http" + "github.com/getarcaneapp/arcane/backend/api/handlers" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/labstack/echo/v4" ) // RegisterWebhookTrigger registers the public (unauthenticated) trigger endpoint. // The token in the URL is the sole authentication mechanism. // Rate-limited via PerIPRateLimitForPaths in router_bootstrap.go. -func RegisterWebhookTrigger(g *echo.Group, webhookService *services.WebhookService) { +func RegisterWebhookTrigger(g *echo.Group, webhookService *services.WebhookService, appCtx handlers.ActivityAppContext) { g.POST("/webhooks/trigger/:token", func(c echo.Context) error { if webhookService == nil { return c.JSON(http.StatusInternalServerError, map[string]any{"success": false, "error": "service not available"}) } token := c.Param("token") - result, err := webhookService.TriggerByToken(c.Request().Context(), token) + result, err := webhookService.TriggerByToken(utils.ActivityRuntimeContext(c.Request().Context(), appCtx.ContextInternal()), token) if err != nil { status := http.StatusInternalServerError if errors.Is(err, services.ErrWebhookNotFound) || errors.Is(err, services.ErrWebhookInvalid) { diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index d4daf87ddd..9ee6a0b430 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -23,6 +23,7 @@ import ( tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/startup" "github.com/getarcaneapp/arcane/backend/pkg/scheduler" + "github.com/getarcaneapp/arcane/backend/pkg/utils" httputils "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "google.golang.org/grpc" ) @@ -49,6 +50,7 @@ func Bootstrap(ctx context.Context) error { slog.InfoContext(ctx, "Arcane Identity Configuration", "PUID", os.Getuid(), "PGID", os.Getgid()) appCtx, cancelApp := context.WithCancel(ctx) + appCtx = utils.WithAppLifecycleContext(appCtx) defer cancelApp() db, err := initializeDBAndMigrate(appCtx, cfg) diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 726b7ee0e8..a937fdab28 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -14,6 +14,7 @@ import ( slogecho "github.com/samber/slog-echo" "github.com/getarcaneapp/arcane/backend/api" + "github.com/getarcaneapp/arcane/backend/api/handlers" "github.com/getarcaneapp/arcane/backend/api/ws" "github.com/getarcaneapp/arcane/backend/frontend" "github.com/getarcaneapp/arcane/backend/internal/config" @@ -160,6 +161,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) apiGroup.Use(middleware.PerIPRateLimitForPaths( []string{"/api/webhooks/trigger/:token"}, 60, 10, )) + handlerAppCtx := handlers.NewActivityAppContext(ctx) tunnelRegistry := edge.NewTunnelRegistry() edge.SetDefaultRegistry(tunnelRegistry) @@ -172,7 +174,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) } // Register public webhook trigger endpoint before auth middleware (token in URL is the sole auth) - api.RegisterWebhookTrigger(apiGroup, appServices.Webhook) //nolint:contextcheck + api.RegisterWebhookTrigger(apiGroup, appServices.Webhook, handlerAppCtx) //nolint:contextcheck // app lifecycle context is intentionally wrapped for detached activity work. //nolint:contextcheck // Echo middleware reads context from echo.Context.Request().Context(), not a parameter. envProxyMiddleware := middleware.NewEnvProxyMiddlewareWithParam( @@ -184,6 +186,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) apiGroup.Use(envProxyMiddleware) humaServices := &api.Services{ + AppContext: ctx, User: appServices.User, Auth: appServices.Auth, Oidc: appServices.Oidc, @@ -223,7 +226,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) Config: cfg, } - _ = api.SetupAPI(e, apiGroup, cfg, humaServices) + _ = api.SetupAPI(e, apiGroup, cfg, humaServices) //nolint:contextcheck // app lifecycle context is carried in humaServices for detached activity work. for _, register := range registerBuildableRoutes { register(apiGroup, appServices) diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go index 7cadce7372..99ab866051 100644 --- a/backend/internal/services/activity_service.go +++ b/backend/internal/services/activity_service.go @@ -275,13 +275,14 @@ func (s *ActivityService) CompleteActivity(ctx context.Context, activityID strin level = models.ActivityMessageLevelWarning case models.ActivityStatusQueued, models.ActivityStatusRunning, models.ActivityStatusSuccess: } - if _, err := s.AppendMessage(context.WithoutCancel(ctx), activityID, AppendActivityMessageRequest{ + activityCtx := utils.ActivityRuntimeContext(ctx, nil) + if _, err := s.AppendMessage(activityCtx, activityID, AppendActivityMessageRequest{ Level: level, Message: finalMessage, }); err != nil { slog.DebugContext(ctx, "failed to append final activity message", "activityId", activityID, "error", err) } - if err := s.db.WithContext(context.WithoutCancel(ctx)).First(&model, "id = ?", activityID).Error; err != nil { + if err := s.db.WithContext(activityCtx).First(&model, "id = ?", activityID).Error; err != nil { slog.DebugContext(ctx, "failed to reload activity after appending message", "activityId", activityID, "error", err) } } diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index c75690a88e..c0ac2e17c4 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -120,7 +120,7 @@ func (s *ImageUpdateService) completeImageUpdateActivityInternal(ctx context.Con message = "Image update check completed" } step := "Image update check complete" - if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage, step); err != nil { + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage, step); err != nil { slog.DebugContext(ctx, "failed to complete image update activity", "activityId", activityID, "error", err) } } @@ -1045,6 +1045,7 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs if err := g.Wait(); err != nil { slog.ErrorContext(ctx, "Batch check error", "error", err) + s.completeImageUpdateActivityInternal(ctx, activityID, false, fmt.Sprintf("Image update check failed: %s", err.Error())) return results, err } diff --git a/backend/internal/services/image_update_service_test.go b/backend/internal/services/image_update_service_test.go index 3440215ea5..786773ae20 100644 --- a/backend/internal/services/image_update_service_test.go +++ b/backend/internal/services/image_update_service_test.go @@ -582,6 +582,53 @@ func TestImageUpdateService_CheckMultipleImages_UsesRegistryFallback(t *testing. assert.Equal(t, remoteDigest, stringPtrToString(saved.LatestDigest)) } +func TestImageUpdateService_CheckMultipleImagesCompletesActivityWhenRequestContextCanceledInternal(t *testing.T) { + db := setupImageUpdateTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Activity{}, &models.ActivityMessage{})) + + activityService := NewActivityService(db) + svc := NewImageUpdateService(db, nil, nil, nil, nil, nil, activityService) + + for range 5 { + require.NoError(t, svc.registryLimiter.Acquire(context.Background(), "docker.io")) + } + defer func() { + for range 5 { + svc.registryLimiter.Release("docker.io") + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := svc.CheckMultipleImages(ctx, []string{"nginx:latest"}, nil) + errCh <- err + }() + + var activity models.Activity + require.Eventually(t, func() bool { + return db.Where("type = ?", models.ActivityTypeImageUpdateCheck).First(&activity).Error == nil + }, time.Second, 10*time.Millisecond) + + cancel() + + select { + case err := <-errCh: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timed out waiting for image update check to return") + } + + require.Eventually(t, func() bool { + if err := db.First(&activity, "id = ?", activity.ID).Error; err != nil { + return false + } + return activity.Status == models.ActivityStatusFailed + }, time.Second, 10*time.Millisecond) + assert.Equal(t, "Image update check complete", activity.Step) + assert.Contains(t, activity.LatestMessage, "Image update check failed") +} + func TestImageUpdateService_CheckMultipleImages_UsesDockerHubCredentialsOnFirstAttempt(t *testing.T) { _, db := setupImageServiceAuthTest(t) require.NoError(t, db.AutoMigrate(&models.ImageUpdateRecord{}, &models.Event{})) diff --git a/backend/internal/services/system_service.go b/backend/internal/services/system_service.go index b31b5ec890..bbf354036c 100644 --- a/backend/internal/services/system_service.go +++ b/backend/internal/services/system_service.go @@ -91,7 +91,7 @@ func (s *SystemService) StartPruneAll(ctx context.Context, environmentID string, return &system.PruneAllResult{Success: true, ActivityID: utils.StringPtrFromTrimmed(activityID)} } - backgroundCtx := context.WithoutCancel(ctx) + backgroundCtx := utils.ActivityRuntimeContext(ctx, nil) go func() { defer s.finishSystemPruneInternal(environmentID) @@ -282,7 +282,7 @@ func (s *SystemService) completeSystemPruneActivityInternal(ctx context.Context, joined := strings.Join(result.Errors, "; ") errMessage = &joined } - if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { slog.DebugContext(ctx, "failed to complete system prune activity", "activityId", activityID, "error", err) } } @@ -433,7 +433,7 @@ func (s *SystemService) completeSystemContainerActivityInternal(ctx context.Cont errMessage = &message } - if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { slog.DebugContext(ctx, "failed to complete system container activity", "activityId", activityID, "error", err) } } diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index d32b9e2d19..e66dccb837 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -490,7 +490,7 @@ func (s *UpdaterService) completeAutoUpdateActivityInternal(ctx context.Context, message = errText } - if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errMessage); err != nil { + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { slog.DebugContext(ctx, "failed to complete auto-update activity", "activityId", activityID, "error", err) } } diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index 7bc73e8ea5..408361521c 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -246,7 +246,7 @@ func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService // ScanImage scans an image for vulnerabilities using Trivy func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imageID string, user models.User) (*vulnerability.ScanResult, error) { - scanCtx := context.WithoutCancel(ctx) + scanCtx := utils.ActivityRuntimeContext(ctx, nil) // Get Docker client to inspect the image dockerClient, err := s.dockerService.GetClient(ctx) @@ -496,7 +496,7 @@ func (s *VulnerabilityService) completeVulnerabilityScanActivityInternal(ctx con } } - if _, err := s.activityService.CompleteActivity(context.WithoutCancel(ctx), activityID, status, message, errorPtr); err != nil { + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errorPtr); err != nil { slog.DebugContext(ctx, "failed to complete vulnerability scan activity", "activityId", activityID, "error", err) } } diff --git a/backend/pkg/libarcane/activity/handler.go b/backend/pkg/libarcane/activity/handler.go index cca3dcdac4..e23f725683 100644 --- a/backend/pkg/libarcane/activity/handler.go +++ b/backend/pkg/libarcane/activity/handler.go @@ -75,7 +75,7 @@ func CompleteHandlerActivity(ctx context.Context, activityService Service, activ finalMessage = errText } - activityCtx := context.WithoutCancel(ctx) + activityCtx := utils.ActivityRuntimeContext(ctx, nil) if _, completeErr := activityService.CompleteActivity(activityCtx, activityID, status, finalMessage, errMessage); completeErr != nil { slog.DebugContext(activityCtx, "failed to complete background activity", "activityId", activityID, "error", completeErr) } diff --git a/backend/pkg/libarcane/activity/writer.go b/backend/pkg/libarcane/activity/writer.go index 9931d4fbc5..e56e5af34d 100644 --- a/backend/pkg/libarcane/activity/writer.go +++ b/backend/pkg/libarcane/activity/writer.go @@ -72,9 +72,8 @@ func NewWriter(ctx context.Context, activityService MessageAppender, activityID func (w *Writer) Write(p []byte) (int, error) { if w.writer != nil { - if _, err := w.writer.Write(p); err != nil { - return 0, err - } + // Keep activity capture alive when the client-side response stream disconnects. + _, _ = w.writer.Write(p) } w.mu.Lock() diff --git a/backend/pkg/libarcane/activity/writer_test.go b/backend/pkg/libarcane/activity/writer_test.go new file mode 100644 index 0000000000..ae02cbe6f4 --- /dev/null +++ b/backend/pkg/libarcane/activity/writer_test.go @@ -0,0 +1,54 @@ +package activity + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/models" + activitytypes "github.com/getarcaneapp/arcane/types/activity" + "github.com/stretchr/testify/require" +) + +type recordingAppender struct { + messages []AppendMessageRequest +} + +func (a *recordingAppender) AppendMessage(_ context.Context, _ string, req AppendMessageRequest) (*activitytypes.Message, error) { + a.messages = append(a.messages, req) + return &activitytypes.Message{}, nil +} + +type failingWriter struct{} + +func (f failingWriter) Write(_ []byte) (int, error) { + return 0, errors.New("client disconnected") +} + +func TestWriterContinuesActivityCaptureWhenResponseWriterFailsInternal(t *testing.T) { + appender := &recordingAppender{} + writer := NewWriter(context.Background(), appender, "activity-1", failingWriter{}, "Pulling image") + + n, err := writer.Write([]byte("Downloading layer\n")) + require.NoError(t, err) + require.Equal(t, len("Downloading layer\n"), n) + + FlushWriter(writer) + require.Eventually(t, func() bool { + return len(appender.messages) == 1 + }, time.Second, 10*time.Millisecond) + require.Equal(t, "Downloading layer", appender.messages[0].Message) + require.Equal(t, models.ActivityMessageLevelInfo, appender.messages[0].Level) +} + +func TestWriterReturnsWrappedWriteErrorWithoutActivityInternal(t *testing.T) { + writer := NewWriter(context.Background(), nil, "", failingWriter{}, "Pulling image") + + _, err := writer.Write([]byte("Downloading layer\n")) + require.ErrorContains(t, err, "client disconnected") +} + +var _ MessageAppender = (*recordingAppender)(nil) +var _ io.Writer = failingWriter{} diff --git a/backend/pkg/projects/cmds.go b/backend/pkg/projects/cmds.go index 1117196887..52afa8ff2a 100644 --- a/backend/pkg/projects/cmds.go +++ b/backend/pkg/projects/cmds.go @@ -11,6 +11,8 @@ import ( "github.com/docker/compose/v5/pkg/api" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" + + "github.com/getarcaneapp/arcane/backend/pkg/utils" ) // ProgressWriterKey can be set on a context to enable JSON-line progress updates. @@ -45,6 +47,9 @@ const defaultComposeTimeout = 30 * time.Minute // timeouts and proxy deadline cancellations. A standalone timeout is applied // so the operation cannot run forever. See #1209. func detachFromHTTPContextInternal(parent context.Context) (context.Context, context.CancelFunc) { + if utils.IsAppLifecycleContext(parent) { + return context.WithTimeout(parent, defaultComposeTimeout) + } ctx := context.WithoutCancel(parent) return context.WithTimeout(ctx, defaultComposeTimeout) } diff --git a/backend/pkg/projects/cmds_test.go b/backend/pkg/projects/cmds_test.go index c462e8af7f..c45ff6126f 100644 --- a/backend/pkg/projects/cmds_test.go +++ b/backend/pkg/projects/cmds_test.go @@ -6,6 +6,7 @@ import ( "time" composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/stretchr/testify/require" ) @@ -59,6 +60,16 @@ func TestDetachFromHTTPContextInternal(t *testing.T) { require.True(t, ok) require.InDelta(t, float64(defaultComposeTimeout), float64(time.Until(deadline)), float64(5*time.Second)) }) + + t.Run("app lifecycle context cancels detached work on shutdown", func(t *testing.T) { + appCtx, cancelApp := context.WithCancel(utils.WithAppLifecycleContext(context.Background())) + detached, detachedCancel := detachFromHTTPContextInternal(appCtx) + defer detachedCancel() + + cancelApp() + + require.ErrorIs(t, detached.Err(), context.Canceled) + }) } func TestComposeStopSkipsWhenNoServicesSpecified(t *testing.T) { diff --git a/backend/pkg/utils/activity_context.go b/backend/pkg/utils/activity_context.go new file mode 100644 index 0000000000..c13f3a6666 --- /dev/null +++ b/backend/pkg/utils/activity_context.go @@ -0,0 +1,73 @@ +package utils + +import ( + "context" + "time" +) + +type appLifecycleContextKey struct{} + +type activityRuntimeContext struct { + valueCtx context.Context + lifecycleCtx context.Context +} + +// WithAppLifecycleContext marks ctx as the application lifecycle context. +func WithAppLifecycleContext(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, appLifecycleContextKey{}, true) +} + +// IsAppLifecycleContext reports whether ctx is tied to the application lifecycle. +func IsAppLifecycleContext(ctx context.Context) bool { + isLifecycle, _ := ctx.Value(appLifecycleContextKey{}).(bool) + return isLifecycle +} + +// ActivityRuntimeContext returns a context suitable for activity-backed work. +func ActivityRuntimeContext(requestCtx context.Context, appCtx context.Context) context.Context { + if appCtx != nil { + if requestCtx == nil || requestCtx == appCtx { + return appCtx + } + return activityRuntimeContext{ + valueCtx: requestCtx, + lifecycleCtx: appCtx, + } + } + if IsAppLifecycleContext(requestCtx) { + return requestCtx + } + if requestCtx != nil { + return context.WithoutCancel(requestCtx) + } + return context.Background() +} + +func (c activityRuntimeContext) Deadline() (time.Time, bool) { + return c.lifecycleCtx.Deadline() +} + +func (c activityRuntimeContext) Done() <-chan struct{} { + return c.lifecycleCtx.Done() +} + +func (c activityRuntimeContext) Err() error { + return c.lifecycleCtx.Err() +} + +func (c activityRuntimeContext) Value(key any) any { + if _, ok := key.(appLifecycleContextKey); ok { + if value := c.lifecycleCtx.Value(key); value != nil { + return value + } + } + if c.valueCtx != nil { + if value := c.valueCtx.Value(key); value != nil { + return value + } + } + return c.lifecycleCtx.Value(key) +} diff --git a/backend/pkg/utils/activity_context_test.go b/backend/pkg/utils/activity_context_test.go new file mode 100644 index 0000000000..c9e06490dd --- /dev/null +++ b/backend/pkg/utils/activity_context_test.go @@ -0,0 +1,82 @@ +package utils + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type activityContextTestKey string + +func TestActivityRuntimeContextPrefersAppContextInternal(t *testing.T) { + requestCtx, cancelRequest := context.WithCancel(context.Background()) + appCtx, cancelApp := context.WithCancel(context.Background()) + defer cancelApp() + + ctx := ActivityRuntimeContext(requestCtx, appCtx) + + cancelRequest() + require.NoError(t, ctx.Err()) + + cancelApp() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestActivityRuntimeContextPreservesRequestValuesWithAppCancellationInternal(t *testing.T) { + requestCtx, cancelRequest := context.WithCancel(context.WithValue(context.Background(), activityContextTestKey("request-id"), "req-123")) + appCtx, cancelApp := context.WithCancel(WithAppLifecycleContext(context.Background())) + defer cancelApp() + + ctx := ActivityRuntimeContext(requestCtx, appCtx) + + require.Equal(t, "req-123", ctx.Value(activityContextTestKey("request-id"))) + require.True(t, IsAppLifecycleContext(ctx)) + + cancelRequest() + require.NoError(t, ctx.Err()) + + cancelApp() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestActivityRuntimeContextUsesAppDeadlineWithRequestValuesInternal(t *testing.T) { + requestDeadline := time.Now().Add(time.Hour) + appDeadline := time.Now().Add(time.Minute) + requestCtx, cancelRequest := context.WithDeadline(context.WithValue(context.Background(), activityContextTestKey("trace-id"), "trace-123"), requestDeadline) + defer cancelRequest() + appCtx, cancelApp := context.WithDeadline(context.Background(), appDeadline) + defer cancelApp() + + ctx := ActivityRuntimeContext(requestCtx, appCtx) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, appDeadline, deadline) + require.Equal(t, "trace-123", ctx.Value(activityContextTestKey("trace-id"))) +} + +func TestActivityRuntimeContextFallsBackToDetachedRequestContextInternal(t *testing.T) { + requestCtx, cancelRequest := context.WithCancel(context.Background()) + + ctx := ActivityRuntimeContext(requestCtx, nil) + + cancelRequest() + require.NoError(t, ctx.Err()) +} + +func TestActivityRuntimeContextPreservesMarkedAppContextInternal(t *testing.T) { + appCtx, cancelApp := context.WithCancel(WithAppLifecycleContext(context.Background())) + + ctx := ActivityRuntimeContext(appCtx, nil) + + cancelApp() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestWithAppLifecycleContextMarksContextInternal(t *testing.T) { + ctx := WithAppLifecycleContext(context.Background()) + + require.True(t, IsAppLifecycleContext(ctx)) +} From 012ac08fcd71c9f91ba179f988874418a1940ad7 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 28 May 2026 20:25:50 -0500 Subject: [PATCH 16/40] fix: add the ability to cancel ongoing activities (#2756) --- backend/api/handlers/activities.go | 89 ++++++++++ backend/api/handlers/containers.go | 10 +- backend/api/handlers/images.go | 4 +- backend/api/handlers/networks.go | 6 +- backend/api/handlers/projects.go | 20 +-- backend/api/handlers/volumes.go | 22 +-- backend/internal/services/activity_service.go | 166 ++++++++++++++++++ .../services/activity_service_test.go | 64 +++++++ .../internal/services/image_update_service.go | 8 + backend/internal/services/system_service.go | 16 +- backend/internal/services/updater_service.go | 7 + .../services/vulnerability_service.go | 19 +- backend/pkg/authz/catalog.go | 1 + backend/pkg/authz/permissions.go | 5 +- backend/pkg/libarcane/activity/handler.go | 63 +++++-- backend/pkg/libarcane/activity/requests.go | 8 + frontend/messages/en.json | 7 + .../components/activity/activity-cancel.ts | 34 ++++ .../activity/activity-center.svelte | 46 +++-- .../activity/activity-detail-panel.svelte | 18 +- frontend/src/lib/services/activity-service.ts | 5 + .../src/lib/stores/activity.store.svelte.ts | 19 ++ frontend/src/routes/(app)/images/+page.svelte | 15 +- 23 files changed, 579 insertions(+), 73 deletions(-) create mode 100644 frontend/src/lib/components/activity/activity-cancel.ts diff --git a/backend/api/handlers/activities.go b/backend/api/handlers/activities.go index 27cbed91ab..c7a7185b19 100644 --- a/backend/api/handlers/activities.go +++ b/backend/api/handlers/activities.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "github.com/danielgtaylor/huma/v2" @@ -64,6 +65,16 @@ type StreamActivitiesInput struct { Limit int `query:"limit" default:"50" doc:"Initial snapshot limit"` } +type CancelActivityInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + ActivityID string `path:"activityId" doc:"Activity ID"` + RequestedBy string `query:"requestedBy" doc:"Display name to attribute the cancellation to (used when proxying to a remote environment)"` +} + +type CancelActivityOutput struct { + Body base.ApiResponse[activity.Activity] +} + func RegisterActivities(api huma.API, activityService *services.ActivityService, environmentService *services.EnvironmentService) { h := &ActivityHandler{ activityService: activityService, @@ -112,6 +123,20 @@ func RegisterActivities(api huma.API, activityService *services.ActivityService, Middlewares: humamw.RequirePermission(api, authz.PermActivitiesRead), }, h.StreamActivities) + huma.Register(api, huma.Operation{ + OperationID: "cancel-activity", + Method: http.MethodPost, + Path: "/environments/{id}/activities/{activityId}/cancel", + Summary: "Cancel a background activity", + Description: "Request cancellation of a running or queued background activity", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermActivitiesCancel), + }, h.CancelActivity) + huma.Register(api, huma.Operation{ OperationID: "clear-activity-history", Method: http.MethodDelete, @@ -308,6 +333,70 @@ func (h *ActivityHandler) ClearHistory(ctx context.Context, input *ClearActivity }, nil } +func (h *ActivityHandler) CancelActivity(ctx context.Context, input *CancelActivityInput) (*CancelActivityOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyCancelActivityInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if input.ActivityID == "" { + return nil, huma.Error400BadRequest("activity id is required") + } + + requestedBy := h.cancelRequestedByInternal(ctx, input.RequestedBy) + cancelled, err := h.activityService.CancelActivity(ctx, input.EnvironmentID, input.ActivityID, requestedBy) + if err != nil { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return nil, huma.Error404NotFound("activity not found") + case errors.Is(err, services.ErrActivityNotCancelable): + return nil, huma.Error409Conflict("activity is not running") + default: + return nil, huma.Error500InternalServerError(err.Error()) + } + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, cancelled) + + return &CancelActivityOutput{ + Body: base.ApiResponse[activity.Activity]{ + Success: true, + Data: *cancelled, + }, + }, nil +} + +func (h *ActivityHandler) proxyCancelActivityInternal(ctx context.Context, input *CancelActivityInput) (*CancelActivityOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + path := fmt.Sprintf("/api/environments/0/activities/%s/cancel", url.PathEscape(input.ActivityID)) + if requestedBy := h.cancelRequestedByInternal(ctx, input.RequestedBy); requestedBy != "" { + path += "?requestedBy=" + url.QueryEscape(requestedBy) + } + out, err := proxyRemoteJSONInternal[base.ApiResponse[activity.Activity]](ctx, h.environmentService, input.EnvironmentID, http.MethodPost, path, nil) + if err != nil { + return nil, err + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &out.Data) + return &CancelActivityOutput{Body: *out}, nil +} + +// cancelRequestedByInternal resolves a human-readable name for the cancellation +// audit message, preferring the authenticated user and falling back to a name +// forwarded from a proxying controller. +func (h *ActivityHandler) cancelRequestedByInternal(ctx context.Context, forwarded string) string { + if user, ok := humamw.GetCurrentUserFromContext(ctx); ok && user != nil { + if user.DisplayName != nil && strings.TrimSpace(*user.DisplayName) != "" { + return strings.TrimSpace(*user.DisplayName) + } + if name := strings.TrimSpace(user.Username); name != "" { + return name + } + } + return strings.TrimSpace(forwarded) +} + func (h *ActivityHandler) streamRemoteActivitySnapshotsInternal( ctx context.Context, input *StreamActivitiesInput, diff --git a/backend/api/handlers/containers.go b/backend/api/handlers/containers.go index ead17960c7..db8d0d62d8 100644 --- a/backend/api/handlers/containers.go +++ b/backend/api/handlers/containers.go @@ -625,7 +625,7 @@ func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerA } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID}) if err := h.containerService.StartContainer(runtimeCtx, input.ContainerID, *user); err != nil { activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container started", err) return nil, huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) @@ -651,7 +651,7 @@ func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerAc } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID}) if err := h.containerService.StopContainer(runtimeCtx, input.ContainerID, *user); err != nil { activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container stopped", err) return nil, huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) @@ -677,7 +677,7 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID}) if err := h.containerService.RestartContainer(runtimeCtx, input.ContainerID, *user); err != nil { activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container restarted", err) return nil, huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) @@ -703,7 +703,7 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID}) activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Redeploying container") redeployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) newContainerID, err := h.containerService.RedeployContainer(redeployCtx, input.ContainerID, *user) @@ -752,7 +752,7 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes}) if err := h.containerService.DeleteContainer(runtimeCtx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil { activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container deleted", err) return nil, huma.Error500InternalServerError((&common.ContainerDeleteError{Err: err}).Error()) diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index 63a85fcae9..b6242dc1c9 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -453,7 +453,7 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() - activityID := activitylib.StartHandlerActivityForUser( + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, h.activityService, input.EnvironmentID, @@ -512,7 +512,7 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) ( if strings.TrimSpace(resourceName) == "" { resourceName = input.Body.ContextDir } - activityID := activitylib.StartHandlerActivityForUser( + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, h.activityService, input.EnvironmentID, diff --git a/backend/api/handlers/networks.go b/backend/api/handlers/networks.go index bd7039d427..20c06484d0 100644 --- a/backend/api/handlers/networks.go +++ b/backend/api/handlers/networks.go @@ -302,7 +302,7 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork "action": "create_network", "driver": input.Body.Options.Driver, }, - }, func() error { + }, func(runtimeCtx context.Context) error { var createErr error response, createErr = h.networkService.CreateNetwork(runtimeCtx, input.Body.Name, dockerOptions, *user) return createErr @@ -447,7 +447,7 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork Metadata: models.JSON{ "action": "remove_network", }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.networkService.RemoveNetwork(runtimeCtx, input.NetworkID, *user) }) if err != nil { @@ -473,7 +473,7 @@ func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworks Message: "Pruning unused networks", SuccessMessage: "Networks pruned successfully", Metadata: models.JSON{"action": "prune_networks"}, - }, func() error { + }, func(runtimeCtx context.Context) error { var pruneErr error report, pruneErr = h.networkService.PruneNetworks(runtimeCtx) return pruneErr diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index e2f1196874..68d4243380 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -602,7 +602,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() - activityID := activitylib.StartHandlerActivityForUser( + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, h.activityService, input.EnvironmentID, @@ -658,7 +658,7 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID}) activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Stopping project") downCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.DownProject(downCtx, input.ProjectID, *user); err != nil { @@ -708,7 +708,7 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject Message: "Creating project", SuccessMessage: "Project created successfully", Metadata: models.JSON{"action": "create_project"}, - }, func() error { + }, func(runtimeCtx context.Context) error { var createErr error proj, createErr = h.projectService.CreateProject(runtimeCtx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) return createErr @@ -865,7 +865,7 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser( + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, h.activityService, input.EnvironmentID, @@ -929,7 +929,7 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes}) activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Destroying project") destroyCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.DestroyProject(destroyCtx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil { @@ -978,7 +978,7 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject Message: "Updating project", SuccessMessage: "Project updated successfully", Metadata: models.JSON{"action": "update_project", "projectID": input.ProjectID}, - }, func() error { + }, func(runtimeCtx context.Context) error { _, updateErr := h.projectService.UpdateProject(runtimeCtx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) return updateErr }) @@ -1031,7 +1031,7 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update "projectID": input.ProjectID, "relativePath": input.Body.RelativePath, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.projectService.UpdateProjectIncludeFile(runtimeCtx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user) }) if err != nil { @@ -1068,7 +1068,7 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID}) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID}) activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Restarting project") restartCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) if err := h.projectService.RestartProject(restartCtx, input.ProjectID, *user); err != nil { @@ -1174,7 +1174,7 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() - activityID := activitylib.StartHandlerActivityForUser( + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, h.activityService, input.EnvironmentID, @@ -1246,7 +1246,7 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() - activityID := activitylib.StartHandlerActivityForUser( + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, h.activityService, input.EnvironmentID, diff --git a/backend/api/handlers/volumes.go b/backend/api/handlers/volumes.go index 8ace14d2a5..2f318272e2 100644 --- a/backend/api/handlers/volumes.go +++ b/backend/api/handlers/volumes.go @@ -766,7 +766,7 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp "action": "create_volume", "driver": input.Body.Driver, }, - }, func() error { + }, func(runtimeCtx context.Context) error { var createErr error response, createErr = h.volumeService.CreateVolume(runtimeCtx, options, *user) return createErr @@ -810,7 +810,7 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp "action": "remove_volume", "force": input.Force, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.DeleteVolume(runtimeCtx, input.VolumeName, input.Force, *user) }) if err != nil { @@ -844,7 +844,7 @@ func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInp Message: "Pruning unused volumes", SuccessMessage: "Volumes pruned successfully", Metadata: models.JSON{"action": "prune_volumes"}, - }, func() error { + }, func(runtimeCtx context.Context) error { var pruneErr error report, pruneErr = h.volumeService.PruneVolumes(runtimeCtx) return pruneErr @@ -1032,7 +1032,7 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) "path": input.Path, "filename": fileHeader.Filename, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.UploadFile(runtimeCtx, input.VolumeName, input.Path, file, fileHeader.Filename, user) }) if err != nil { @@ -1064,7 +1064,7 @@ func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirect "action": "create_volume_directory", "path": input.Path, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.CreateDirectory(runtimeCtx, input.VolumeName, input.Path, user) }) if err != nil { @@ -1096,7 +1096,7 @@ func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) "action": "delete_volume_file", "path": input.Path, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.DeleteFile(runtimeCtx, input.VolumeName, input.Path, user) }) if err != nil { @@ -1183,7 +1183,7 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp Message: "Creating volume backup", SuccessMessage: "Volume backup created successfully", Metadata: models.JSON{"action": "create_volume_backup"}, - }, func() error { + }, func(runtimeCtx context.Context) error { var backupErr error backup, backupErr = h.volumeService.CreateBackup(runtimeCtx, input.VolumeName, *user) return backupErr @@ -1224,7 +1224,7 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI "action": "restore_volume_backup", "backupId": input.BackupID, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.RestoreBackup(runtimeCtx, input.VolumeName, input.BackupID, *user) }) if err != nil { @@ -1268,7 +1268,7 @@ func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBa "backupId": input.BackupID, "paths": input.Body.Paths, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.RestoreBackupFiles(runtimeCtx, input.VolumeName, input.BackupID, input.Body.Paths, *user) }) if err != nil { @@ -1343,7 +1343,7 @@ func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInp "action": "delete_volume_backup", "backupId": input.BackupID, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.DeleteBackup(runtimeCtx, input.BackupID, user) }) if err != nil { @@ -1417,7 +1417,7 @@ func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRe "action": "upload_restore_volume_backup", "filename": fileHeader.Filename, }, - }, func() error { + }, func(runtimeCtx context.Context) error { return h.volumeService.UploadAndRestore(runtimeCtx, input.VolumeName, file, fileHeader.Filename, *user) }) if err != nil { diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go index 99ab866051..f320d49f0d 100644 --- a/backend/internal/services/activity_service.go +++ b/backend/internal/services/activity_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -29,8 +30,18 @@ type ActivityService struct { subscribersMu sync.RWMutex subscribers map[int]*activitySubscriber nextSubID int + + // running maps an active activity ID to the cancel function of its work + // context, so cancellation requests can interrupt in-flight work. Entries + // are added by Track and removed when the activity is completed. + runningMu sync.Mutex + running map[string]context.CancelCauseFunc } +// ErrActivityNotCancelable indicates the activity has already reached a terminal +// state and can no longer be cancelled. +var ErrActivityNotCancelable = errors.New("activity is not cancelable") + type activitySubscriber struct { environmentID string ch chan activitytypes.StreamEvent @@ -45,6 +56,70 @@ func NewActivityService(db *database.DB) *ActivityService { return &ActivityService{ db: db, subscribers: map[int]*activitySubscriber{}, + running: map[string]context.CancelCauseFunc{}, + } +} + +// Track derives a cancelable work context bound to activityID and registers its +// cancel function so RequestCancel can interrupt the work. The registration is +// released when the activity is completed (see CompleteActivity) or when the +// returned context is otherwise no longer needed. Implements activitylib.Tracker. +func (s *ActivityService) Track(ctx context.Context, activityID string) context.Context { + activityID = strings.TrimSpace(activityID) + if s == nil || activityID == "" { + return ctx + } + + workCtx, cancel := context.WithCancelCause(ctx) + s.runningMu.Lock() + if s.running == nil { + s.running = map[string]context.CancelCauseFunc{} + } + if existing, ok := s.running[activityID]; ok { + // Replace any stale registration to avoid leaking the prior context. + existing(nil) + } + s.running[activityID] = cancel + s.runningMu.Unlock() + return workCtx +} + +// RequestCancel cancels the work context registered for activityID, signalling +// activitylib.ErrCanceled as the cause. It returns whether a running activity +// was found in this process. +func (s *ActivityService) RequestCancel(activityID string) bool { + activityID = strings.TrimSpace(activityID) + if s == nil || activityID == "" { + return false + } + + s.runningMu.Lock() + cancel, ok := s.running[activityID] + s.runningMu.Unlock() + if !ok { + return false + } + cancel(activitylib.ErrCanceled) + return true +} + +// releaseCancelInternal removes and cancels the registration for activityID. +// Cancelling with a nil cause is a no-op if the context was already cancelled +// (the first cause wins), so a prior ErrCanceled cause is preserved. +func (s *ActivityService) releaseCancelInternal(activityID string) { + activityID = strings.TrimSpace(activityID) + if s == nil || activityID == "" { + return + } + + s.runningMu.Lock() + cancel, ok := s.running[activityID] + if ok { + delete(s.running, activityID) + } + s.runningMu.Unlock() + if ok { + cancel(nil) } } @@ -247,6 +322,13 @@ func (s *ActivityService) CompleteActivity(ctx context.Context, activityID strin return nil, fmt.Errorf("activity id is required") } + // The activity is reaching a terminal state; release any cancel registration. + s.releaseCancelInternal(activityID) + + // Detach from cancellation so the terminal write always lands — completion is + // often triggered precisely because the work context was cancelled. + ctx = context.WithoutCancel(ctx) + now := time.Now() var model models.Activity if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -292,6 +374,90 @@ func (s *ActivityService) CompleteActivity(ctx context.Context, activityID strin return &dto, nil } +// CancelActivity requests cancellation of a running or queued activity. When the +// activity's work is running in this process it interrupts it (the work finalizes +// its own terminal status); otherwise it marks the activity cancelled directly, +// but only if it is still active. Returns ErrActivityNotCancelable if the activity +// has already reached a terminal state, or gorm.ErrRecordNotFound if it is unknown. +func (s *ActivityService) CancelActivity(ctx context.Context, environmentID, activityID, requestedBy string) (*activitytypes.Activity, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, fmt.Errorf("activity id is required") + } + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + var model models.Activity + if err := s.db.WithContext(ctx).Where("id = ? AND environment_id = ?", activityID, environmentID).First(&model).Error; err != nil { + return nil, err + } + switch model.Status { + case models.ActivityStatusSuccess, models.ActivityStatusFailed, models.ActivityStatusCancelled: + return nil, ErrActivityNotCancelable + case models.ActivityStatusQueued, models.ActivityStatusRunning: + // Active states — cancellation can proceed. + } + + requestedBy = strings.TrimSpace(requestedBy) + if requestedBy == "" { + requestedBy = "a user" + } + writeCtx := utils.ActivityRuntimeContext(ctx, nil) + if _, err := s.AppendMessage(writeCtx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelWarning, + Message: fmt.Sprintf("Cancellation requested by %s", requestedBy), + }); err != nil { + slog.DebugContext(ctx, "failed to append cancellation message", "activityId", activityID, "error", err) + } + + if s.RequestCancel(activityID) { + // The running work observes the cancelled context and writes its own + // terminal status, which reaches clients via the activity stream. Return + // the pre-cancel snapshot rather than reloading here: the worker has not + // finished unwinding yet, so a reload would still report "running". + dto := activityToDTOInternal(&model) + return &dto, nil + } + + // Untracked work (e.g. after a process restart, or a queued activity with no + // runner): finalize directly, but only if it is still active to avoid + // clobbering a concurrently-completing activity. + now := time.Now() + var finalized models.Activity + if err := s.db.WithContext(writeCtx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&finalized, "id = ? AND environment_id = ?", activityID, environmentID).Error; err != nil { + return err + } + updates := completeActivityUpdatesInternal(finalized.StartedAt, models.ActivityStatusCancelled, cancelledMessageInternal, nil, nil, now) + result := tx.Model(&models.Activity{}). + Where("id = ? AND status IN ?", activityID, []models.ActivityStatus{models.ActivityStatusQueued, models.ActivityStatusRunning}). + Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to cancel activity: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrActivityNotCancelable + } + if err := tx.First(&finalized, "id = ? AND environment_id = ?", activityID, environmentID).Error; err != nil { + return fmt.Errorf("failed to load cancelled activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + dto := activityToDTOInternal(&finalized) + s.publishActivityInternal(dto) + return &dto, nil +} + +const cancelledMessageInternal = "Cancelled by user" + func completeActivityUpdatesInternal(startedAt time.Time, status models.ActivityStatus, finalMessage string, errMessage *string, finalStep []string, now time.Time) map[string]any { updates := map[string]any{ "status": status, diff --git a/backend/internal/services/activity_service_test.go b/backend/internal/services/activity_service_test.go index 9554fdef44..44922daf84 100644 --- a/backend/internal/services/activity_service_test.go +++ b/backend/internal/services/activity_service_test.go @@ -11,7 +11,9 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" activitytypes "github.com/getarcaneapp/arcane/types/activity" ) @@ -289,6 +291,68 @@ func TestActivityServiceCompleteActivityRejectsUninitializedServiceInternal(t *t require.Error(t, err) } +func TestActivityServiceTrackAndRequestCancelInternal(t *testing.T) { + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + // Mirror the handler flow: work runs under an app-lifecycle runtime context. + appCtx := utils.WithAppLifecycleContext(context.Background()) + runtimeCtx := utils.ActivityRuntimeContext(context.Background(), appCtx) + + created, err := service.StartActivity(runtimeCtx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeImagePull, + LatestMessage: "running", + }) + require.NoError(t, err) + + workCtx := service.Track(runtimeCtx, created.ID) + require.NoError(t, workCtx.Err()) + + // A tracked activity is found and cancelled with the ErrCanceled cause. + require.True(t, service.RequestCancel(created.ID)) + require.ErrorIs(t, workCtx.Err(), context.Canceled) + require.ErrorIs(t, context.Cause(workCtx), activitylib.ErrCanceled) + require.True(t, activitylib.CancelledByContext(workCtx)) + + // Completion must land even though the work context is cancelled (this is the + // path CompleteHandlerActivity takes after re-wrapping the work context). + completed, err := service.CompleteActivity(utils.ActivityRuntimeContext(workCtx, nil), created.ID, models.ActivityStatusCancelled, "Cancelled by user", nil) + require.NoError(t, err) + require.Equal(t, "cancelled", string(completed.Status)) + require.NotNil(t, completed.EndedAt) + + // Completing the activity releases the registration. + require.False(t, service.RequestCancel(created.ID)) +} + +func TestActivityServiceCancelActivityInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + // An untracked running activity (e.g. after a restart) is finalized directly. + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeSystemPrune, + LatestMessage: "running", + }) + require.NoError(t, err) + + cancelled, err := service.CancelActivity(ctx, "0", created.ID, "Tester") + require.NoError(t, err) + require.Equal(t, "cancelled", string(cancelled.Status)) + require.NotNil(t, cancelled.EndedAt) + + // Cancelling an already-terminal activity is rejected. + _, err = service.CancelActivity(ctx, "0", created.ID, "Tester") + require.ErrorIs(t, err, ErrActivityNotCancelable) + + // Unknown activity reports not found. + _, err = service.CancelActivity(ctx, "0", "missing", "Tester") + require.ErrorIs(t, err, gorm.ErrRecordNotFound) +} + func receiveActivityEventInternal(t *testing.T, events <-chan activitytypes.StreamEvent) activitytypes.StreamEvent { t.Helper() diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index c0ac2e17c4..98f20c54f4 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -12,6 +12,7 @@ import ( ref "github.com/distribution/reference" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" imageupdatecore "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ratelimit" @@ -115,6 +116,11 @@ func (s *ImageUpdateService) completeImageUpdateActivityInternal(ctx context.Con if !success { status = models.ActivityStatusFailed errMessage = utils.StringPtrFromTrimmed(message) + if activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + errMessage = nil + message = "Image update check cancelled" + } } if message == "" { message = "Image update check completed" @@ -128,6 +134,7 @@ func (s *ImageUpdateService) completeImageUpdateActivityInternal(ctx context.Con func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef string) (*imageupdate.Response, error) { startTime := time.Now() activityID := s.startImageUpdateActivityInternal(ctx, imageRef, 1) + ctx = s.activityService.Track(ctx, activityID) s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, fmt.Sprintf("Checking %s", imageRef), 20, "Checking remote digest") parts := s.parseImageReference(imageRef) @@ -987,6 +994,7 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs } activityID := s.startImageUpdateActivityInternal(ctx, fmt.Sprintf("%d images", len(imageRefs)), len(imageRefs)) + ctx = s.activityService.Track(ctx, activityID) s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, fmt.Sprintf("Checking %d image references", len(imageRefs)), 5, "Preparing image update check") slog.DebugContext(ctx, "Starting batch image update check", "imageCount", len(imageRefs), "externalCredCount", len(externalCreds)) diff --git a/backend/internal/services/system_service.go b/backend/internal/services/system_service.go index bbf354036c..94d0efb887 100644 --- a/backend/internal/services/system_service.go +++ b/backend/internal/services/system_service.go @@ -10,6 +10,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/dockerrun" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -79,6 +80,7 @@ func (s *SystemService) PruneAll(ctx context.Context, environmentID string, req defer s.finishSystemPruneInternal(environmentID) + ctx = s.activityService.Track(ctx, activityID) s.runSystemPruneInternal(ctx, req, activityID, result) return result, true, nil @@ -92,6 +94,7 @@ func (s *SystemService) StartPruneAll(ctx context.Context, environmentID string, } backgroundCtx := utils.ActivityRuntimeContext(ctx, nil) + backgroundCtx = s.activityService.Track(backgroundCtx, activityID) go func() { defer s.finishSystemPruneInternal(environmentID) @@ -277,10 +280,15 @@ func (s *SystemService) completeSystemPruneActivityInternal(ctx context.Context, message := "System prune completed" var errMessage *string if !result.Success || len(result.Errors) > 0 { - status = models.ActivityStatusFailed - message = "System prune completed with errors" - joined := strings.Join(result.Errors, "; ") - errMessage = &joined + if activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + message = "System prune cancelled" + } else { + status = models.ActivityStatusFailed + message = "System prune completed with errors" + joined := strings.Join(result.Errors, "; ") + errMessage = &joined + } } if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { slog.DebugContext(ctx, "failed to complete system prune activity", "activityId", activityID, "error", err) diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index e66dccb837..677fc8407f 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -95,6 +95,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (out *up out = &updater.Result{Items: []updater.ResourceResult{}} activityID := s.startAutoUpdateActivityInternal(ctx, dryRun) out.ActivityID = utils.StringPtrFromTrimmed(activityID) + ctx = s.activityService.Track(ctx, activityID) defer func() { if out != nil { out.ActivityID = utils.StringPtrFromTrimmed(activityID) @@ -489,6 +490,11 @@ func (s *UpdaterService) completeAutoUpdateActivityInternal(ctx context.Context, errMessage = &errText message = errText } + if status == models.ActivityStatusFailed && activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + message = "Auto-update cancelled" + errMessage = nil + } if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { slog.DebugContext(ctx, "failed to complete auto-update activity", "activityId", activityID, "error", err) @@ -504,6 +510,7 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID out = &updater.Result{Items: []updater.ResourceResult{}} activityID := s.startSingleContainerUpdateActivityInternal(ctx, containerID) out.ActivityID = utils.StringPtrFromTrimmed(activityID) + ctx = s.activityService.Track(ctx, activityID) defer func() { if out != nil { out.ActivityID = utils.StringPtrFromTrimmed(activityID) diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index 408361521c..f445dae2ba 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -26,6 +26,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" @@ -275,6 +276,9 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag "imageName", imageName, ) activityID := s.startVulnerabilityScanActivityInternal(scanCtx, envID, imageID, imageName, &user) + // Make the scan cancelable: cancelling the activity cancels scanCtx, which + // unblocks the Trivy container wait and triggers its (cancel-safe) teardown. + scanCtx = s.activityService.Track(scanCtx, activityID) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) // Create pending scan record @@ -488,11 +492,16 @@ func (s *VulnerabilityService) completeVulnerabilityScanActivityInternal(ctx con message := "Vulnerability scan completed" var errorPtr *string if !success { - status = models.ActivityStatusFailed - message = "Vulnerability scan failed" - if strings.TrimSpace(errMessage) != "" { - errorPtr = &errMessage - message = errMessage + if activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + message = "Vulnerability scan cancelled" + } else { + status = models.ActivityStatusFailed + message = "Vulnerability scan failed" + if strings.TrimSpace(errMessage) != "" { + errorPtr = &errMessage + message = errMessage + } } } diff --git a/backend/pkg/authz/catalog.go b/backend/pkg/authz/catalog.go index 2629d918ab..07939cce13 100644 --- a/backend/pkg/authz/catalog.go +++ b/backend/pkg/authz/catalog.go @@ -193,6 +193,7 @@ var permissionCatalog = []PermissionCatalogResource{ }}, {"activities", "Activities", PermissionScopeEnv, []PermissionCatalogAction{ {"read", PermActivitiesRead, "Read", ""}, + {"cancel", PermActivitiesCancel, "Cancel", ""}, {"delete", PermActivitiesDelete, "Clear history", ""}, }}, } diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go index 4eb9f7682f..30285539f3 100644 --- a/backend/pkg/authz/permissions.go +++ b/backend/pkg/authz/permissions.go @@ -166,6 +166,7 @@ const ( PermBuildWorkspacesManage = "build-workspaces:manage" PermActivitiesRead = "activities:read" + PermActivitiesCancel = "activities:cancel" PermActivitiesDelete = "activities:delete" ) @@ -276,7 +277,7 @@ func BuiltInEditorPermissions() []string { PermImageUpdatesRead, PermImageUpdatesCheck, PermVulnsRead, PermVulnsScan, PermVulnsManage, PermBuildWorkspacesManage, - PermActivitiesRead, PermActivitiesDelete, + PermActivitiesRead, PermActivitiesCancel, PermActivitiesDelete, } } @@ -300,7 +301,7 @@ func BuiltInDeployerPermissions() []string { PermSystemRead, PermImageUpdatesRead, PermImageUpdatesCheck, PermVulnsRead, - PermActivitiesRead, + PermActivitiesRead, PermActivitiesCancel, } } diff --git a/backend/pkg/libarcane/activity/handler.go b/backend/pkg/libarcane/activity/handler.go index e23f725683..23da05ed5e 100644 --- a/backend/pkg/libarcane/activity/handler.go +++ b/backend/pkg/libarcane/activity/handler.go @@ -3,6 +3,7 @@ package activity import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -12,6 +13,22 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/utils" ) +// ErrCanceled is the cancellation cause set on an activity's work context when a +// user requests cancellation. Completion paths read context.Cause to record a +// cancelled (rather than failed) terminal status. +var ErrCanceled = errors.New("activity cancelled by user") + +// cancelledMessage is the latest-message recorded when work is cancelled. +const cancelledMessage = "Cancelled by user" + +// CancelledByContext reports whether ctx was cancelled by a user cancellation +// request (as opposed to app shutdown or a deadline). Callers that finalize an +// activity from a possibly-cancelled work context use this to choose between a +// cancelled and a failed terminal status. +func CancelledByContext(ctx context.Context) bool { + return ctx != nil && errors.Is(context.Cause(ctx), ErrCanceled) +} + type HandlerOptions struct { EnvironmentID string Type models.ActivityType @@ -25,6 +42,12 @@ type HandlerOptions struct { Metadata models.JSON } +// StartHandlerActivityForUser creates a background activity and returns its ID +// along with a work context the caller MUST use for the underlying operation. +// When the service supports cancellation (implements Tracker), the returned +// context is a cancelable child bound to the activity; cancelling the activity +// cancels this context. The activity registration is released when the activity +// is completed via the service. On failure it returns ("", ctx) unchanged. func StartHandlerActivityForUser( ctx context.Context, activityService Service, @@ -37,9 +60,9 @@ func StartHandlerActivityForUser( step string, message string, metadata models.JSON, -) string { +) (string, context.Context) { if activityService == nil { - return "" + return "", ctx } activity, err := activityService.StartActivity(ctx, StartRequest{ @@ -55,9 +78,14 @@ func StartHandlerActivityForUser( }) if err != nil { slog.DebugContext(ctx, "failed to start background activity", "type", activityType, "error", err) - return "" + return "", ctx + } + + workCtx := ctx + if tracker, ok := activityService.(Tracker); ok { + workCtx = tracker.Track(ctx, activity.ID) } - return activity.ID + return activity.ID, workCtx } func CompleteHandlerActivity(ctx context.Context, activityService Service, activityID string, successMessage string, err error) { @@ -69,10 +97,17 @@ func CompleteHandlerActivity(ctx context.Context, activityService Service, activ var errMessage *string finalMessage := successMessage if err != nil { - status = models.ActivityStatusFailed - errText := err.Error() - errMessage = &errText - finalMessage = errText + // Read the cancellation cause from the (possibly-tracked) work context + // before it is re-wrapped for the DB write below. + if CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + finalMessage = cancelledMessage + } else { + status = models.ActivityStatusFailed + errText := err.Error() + errMessage = &errText + finalMessage = errText + } } activityCtx := utils.ActivityRuntimeContext(ctx, nil) @@ -81,8 +116,12 @@ func CompleteHandlerActivity(ctx context.Context, activityService Service, activ } } -func RunHandlerActivity(ctx context.Context, activityService Service, opts HandlerOptions, action func() error) (string, error) { - activityID := StartHandlerActivityForUser( +// RunHandlerActivity starts an activity, runs action with the activity's work +// context (cancelable when the service supports it), and completes the activity. +// The action MUST use the provided context for its operation so cancellation +// propagates. +func RunHandlerActivity(ctx context.Context, activityService Service, opts HandlerOptions, action func(ctx context.Context) error) (string, error) { + activityID, workCtx := StartHandlerActivityForUser( ctx, activityService, opts.EnvironmentID, @@ -96,8 +135,8 @@ func RunHandlerActivity(ctx context.Context, activityService Service, opts Handl opts.Metadata, ) - err := action() - CompleteHandlerActivity(ctx, activityService, activityID, opts.SuccessMessage, err) + err := action(workCtx) + CompleteHandlerActivity(workCtx, activityService, activityID, opts.SuccessMessage, err) return activityID, err } diff --git a/backend/pkg/libarcane/activity/requests.go b/backend/pkg/libarcane/activity/requests.go index a9f70e37ff..cddfb7dff2 100644 --- a/backend/pkg/libarcane/activity/requests.go +++ b/backend/pkg/libarcane/activity/requests.go @@ -16,6 +16,14 @@ type MessageAppender interface { AppendMessage(ctx context.Context, activityID string, req AppendMessageRequest) (*activitytypes.Message, error) } +// Tracker is an optional interface a Service may implement to make activities +// cancelable. Track derives a cancelable context bound to the activity ID and +// registers it so the activity can later be cancelled via the activity service. +// Implementers release the registration when the activity completes. +type Tracker interface { + Track(ctx context.Context, activityID string) context.Context +} + type StartRequest struct { EnvironmentID string Type models.ActivityType diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cef1c449ae..2d91be8fb4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2028,6 +2028,13 @@ "activity_clear_history_confirm": "Clear History", "activity_clear_history_success": "Activity history cleared", "activity_clear_history_failed": "Failed to clear activity history", + "activity_cancel": "Cancel activity", + "activity_cancel_title": "Cancel activity?", + "activity_cancel_message": "This requests cancellation of the running operation. Work already completed by Docker may not be rolled back.", + "activity_cancel_confirm": "Cancel Activity", + "activity_cancel_success": "Cancellation requested", + "activity_cancel_failed": "Failed to cancel activity", + "activity_cancel_already_finished": "Activity already finished", "activity_settings_title": "Activity", "activity_settings_description": "Configure Activity Center history retention for each environment.", "activity_settings_saved": "Activity settings saved", diff --git a/frontend/src/lib/components/activity/activity-cancel.ts b/frontend/src/lib/components/activity/activity-cancel.ts new file mode 100644 index 0000000000..8fcc44be13 --- /dev/null +++ b/frontend/src/lib/components/activity/activity-cancel.ts @@ -0,0 +1,34 @@ +import { openConfirmDialog } from '$lib/components/confirm-dialog'; +import { toast } from 'svelte-sonner'; +import { m } from '$lib/paraglide/messages'; +import { activityStore } from '$lib/stores/activity.store.svelte'; + +/** + * Opens a confirmation dialog and, on confirm, requests cancellation of the + * activity. Shared by the activity center row action and the detail panel so the + * confirm copy, toasts, and the already-finished (409) handling stay in one place. + */ +export function confirmCancelActivity(activityId: string) { + openConfirmDialog({ + title: m.activity_cancel_title(), + message: m.activity_cancel_message(), + confirm: { + label: m.activity_cancel_confirm(), + destructive: true, + action: async () => { + try { + await activityStore.cancelActivity(activityId); + toast.success(m.activity_cancel_success()); + } catch (error) { + if ((error as { response?: { status?: number } })?.response?.status === 409) { + toast.info(m.activity_cancel_already_finished()); + await activityStore.refresh(); + return; + } + console.error('Failed to cancel activity:', error); + toast.error(m.activity_cancel_failed()); + } + } + } + }); +} diff --git a/frontend/src/lib/components/activity/activity-center.svelte b/frontend/src/lib/components/activity/activity-center.svelte index d7e51cc468..6f93ac67f6 100644 --- a/frontend/src/lib/components/activity/activity-center.svelte +++ b/frontend/src/lib/components/activity/activity-center.svelte @@ -6,10 +6,11 @@ import ActivityDetailPanel from './activity-detail-panel.svelte'; import { activityStore } from '$lib/stores/activity.store.svelte'; import type { ActivityFilter } from '$lib/types/activity.type'; - import { ActivityIcon, RefreshIcon, TrashIcon } from '$lib/icons'; + import { ActivityIcon, CloseIcon, RefreshIcon, TrashIcon } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import { cn } from '$lib/utils'; import { activityFilterLabel } from './activity-labels'; + import { confirmCancelActivity } from './activity-cancel'; import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { toast } from 'svelte-sonner'; import IfPermitted from '$lib/components/if-permitted.svelte'; @@ -122,19 +123,36 @@
{#each activityStore.filteredActivities as activity (activity.id)} {@const expanded = activityStore.isExpanded(activity.id)} - activityStore.setActivityExpanded(activity.id, open)}> - - - - - - - + {@const cancelable = activity.status === 'running' || activity.status === 'queued'} +
+ activityStore.setActivityExpanded(activity.id, open)}> + + + + + + + + {#if cancelable} + + + + {/if} +
{/each}
{/if} diff --git a/frontend/src/lib/components/activity/activity-detail-panel.svelte b/frontend/src/lib/components/activity/activity-detail-panel.svelte index eec062fa8f..ab92cf7994 100644 --- a/frontend/src/lib/components/activity/activity-detail-panel.svelte +++ b/frontend/src/lib/components/activity/activity-detail-panel.svelte @@ -4,9 +4,11 @@ import { CopyButton } from '$lib/components/ui/copy-button'; import { activityStore } from '$lib/stores/activity.store.svelte'; import type { Activity, ActivityMessage } from '$lib/types/activity.type'; - import { ActivityIcon, TerminalIcon } from '$lib/icons'; + import { ActivityIcon, CloseIcon, TerminalIcon } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import { cn } from '$lib/utils'; + import IfPermitted from '$lib/components/if-permitted.svelte'; + import { confirmCancelActivity } from './activity-cancel'; import { activityStatusLabel, activityStatusVariant, activityTypeIcon, activityTypeLabel } from './activity-labels'; let { activity }: { activity: Activity } = $props(); @@ -30,6 +32,7 @@ liveActivity.sourceEnvironmentName || liveActivity.sourceEnvironmentId || liveActivity.environmentId ); const startedByName = $derived(liveActivity.startedBy?.displayName || liveActivity.startedBy?.username); + const cancelable = $derived(liveActivity.status === 'running' || liveActivity.status === 'queued'); $effect(() => { messages.length; @@ -118,6 +121,19 @@

{activityTarget}

+ {#if cancelable} + + + + {/if}
diff --git a/frontend/src/lib/services/activity-service.ts b/frontend/src/lib/services/activity-service.ts index 45ec59d003..1fd9bad51a 100644 --- a/frontend/src/lib/services/activity-service.ts +++ b/frontend/src/lib/services/activity-service.ts @@ -21,6 +21,11 @@ export class ActivityService extends BaseAPIService { return this.handleResponse(this.api.get(`/environments/${envId}/activities/${activityId}`, { params: { limit } })); } + async cancelActivity(activityId: string, environmentId?: string): Promise { + const envId = await this.resolveEnvironmentId(environmentId); + return this.handleResponse(this.api.post(`/environments/${envId}/activities/${encodeURIComponent(activityId)}/cancel`)); + } + async clearHistory(environmentId?: string): Promise { const envId = await this.resolveEnvironmentId(environmentId); return this.handleResponse(this.api.delete(`/environments/${envId}/activities/history`)); diff --git a/frontend/src/lib/stores/activity.store.svelte.ts b/frontend/src/lib/stores/activity.store.svelte.ts index a764f01c82..90ed0fe8a1 100644 --- a/frontend/src/lib/stores/activity.store.svelte.ts +++ b/frontend/src/lib/stores/activity.store.svelte.ts @@ -50,6 +50,7 @@ function createActivityStore() { let _expandedActivityIds = $state>({}); let _detailLoadingIds = $state>({}); let _detailErrorIds = $state>({}); + let _cancellingIds = $state>({}); let _filter = $state('running'); let _open = $state(false); let _loading = $state(false); @@ -363,6 +364,9 @@ function createActivityStore() { isDetailError(activityId: string): boolean { return !!_detailErrorIds[activityId]; }, + isCancelling(activityId: string): boolean { + return !!_cancellingIds[activityId]; + }, getDetail(activityId: string): ActivityDetail | null { const activity = _details[activityId]?.activity ?? _activities.find((item) => item.id === activityId); if (!activity) { @@ -393,6 +397,21 @@ function createActivityStore() { abortStreamInternal(); }, refresh: () => refreshInternal(), + cancelActivity: async (activityId: string) => { + if (!activityId || _cancellingIds[activityId]) { + return; + } + _cancellingIds = { ..._cancellingIds, [activityId]: true }; + try { + // The cancelled status arrives via the stream (mergeActivityInternal); + // callers handle success/error toasts. + await activityService.cancelActivity(activityId, _currentEnvironmentId); + } finally { + const next = { ..._cancellingIds }; + delete next[activityId]; + _cancellingIds = next; + } + }, clearHistory: async () => { await activityService.clearHistory(_currentEnvironmentId); _details = {}; diff --git a/frontend/src/routes/(app)/images/+page.svelte b/frontend/src/routes/(app)/images/+page.svelte index 8c8b9f7751..dcb40bcd63 100644 --- a/frontend/src/routes/(app)/images/+page.svelte +++ b/frontend/src/routes/(app)/images/+page.svelte @@ -109,9 +109,11 @@ const imageUsageCounts = $derived(imageUsageCountsQuery.data ?? imageUsageFallback); - const isRefreshing = $derived( - (imagesQuery.isFetching && !imagesQuery.isPending) || (imageUsageCountsQuery.isFetching && !imageUsageCountsQuery.isPending) - ); + // Intentionally a manual flag, not $derived(query.isFetching): these queries use + // aggressive background refetching (staleTime: 0 + refetchOnMount/WindowFocus: + // 'always'), so deriving from isFetching leaves the manual refresh button spinning + // constantly. Mirrors the containers page — reflects only user-initiated refreshes. + let isRefreshing = $state(false); const isUploading = $derived(uploadImagesMutation.isPending); const isPruning = $derived(pruneImagesMutation.isPending); const isChecking = $derived(checkUpdatesMutation.isPending); @@ -147,7 +149,12 @@ ]; async function refresh() { - await Promise.all([imagesQuery.refetch(), imageUsageCountsQuery.refetch()]); + isRefreshing = true; + try { + await Promise.all([imagesQuery.refetch(), imageUsageCountsQuery.refetch()]); + } finally { + isRefreshing = false; + } } const canPullImage = $derived(hasPermission('images:pull', envId)); From 085b012a980325f974443d89be0b64ee486fc3c0 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 28 May 2026 23:09:00 -0500 Subject: [PATCH 17/40] refactor: consolidate frontend logic / components (#2757) --- frontend/messages/en.json | 4 + .../arcane-table/arcane-table.svelte | 15 +- .../arcane-table/arcane-table.types.svelte.ts | 2 +- .../arcane-table/arcane-table.utils.ts | 17 ++ .../src/lib/components/copy-button.svelte | 61 +++++++ frontend/src/lib/components/stat-card.svelte | 47 ++++-- .../lib/components/states/empty-state.svelte | 50 ++++++ frontend/src/lib/components/states/index.ts | 2 + .../lib/components/states/inline-error.svelte | 42 +++++ .../lib/layouts/resource-page-layout.svelte | 6 + frontend/src/lib/utils/bulk-actions.ts | 156 ++++++++++++++++++ .../containers/[containerId]/+error.svelte | 36 ++++ .../containers/container-table.actions.ts | 106 ++++-------- .../(app)/images/[imageId]/+error.svelte | 36 ++++ .../routes/(app)/images/image-table.svelte | 67 ++------ .../(app)/networks/[networkId]/+error.svelte | 36 ++++ .../(app)/networks/network-table.svelte | 57 ++----- .../src/routes/(app)/settings/+page.svelte | 5 +- .../(app)/volumes/[volumeName]/+error.svelte | 36 ++++ .../routes/(app)/volumes/volume-table.svelte | 73 +++----- 20 files changed, 630 insertions(+), 224 deletions(-) create mode 100644 frontend/src/lib/components/copy-button.svelte create mode 100644 frontend/src/lib/components/states/empty-state.svelte create mode 100644 frontend/src/lib/components/states/index.ts create mode 100644 frontend/src/lib/components/states/inline-error.svelte create mode 100644 frontend/src/lib/utils/bulk-actions.ts create mode 100644 frontend/src/routes/(app)/containers/[containerId]/+error.svelte create mode 100644 frontend/src/routes/(app)/images/[imageId]/+error.svelte create mode 100644 frontend/src/routes/(app)/networks/[networkId]/+error.svelte create mode 100644 frontend/src/routes/(app)/volumes/[volumeName]/+error.svelte diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2d91be8fb4..82490a8407 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -146,6 +146,8 @@ "common_standby": "Standby", "common_offline": "Offline", "common_copy": "Copy", + "common_copied": "Copied to clipboard", + "common_copy_failed": "Failed to copy", "common_copy_json": "Copy JSON", "common_close": "Close", "common_invalid_email": "Must be a valid email", @@ -288,8 +290,10 @@ "common_redeploy_success": "{type} Redeployed Successfully", "common_bulk_remove_success": "Successfully removed {count} {resource}", "common_bulk_remove_failed": "Failed to remove {count} {resource}", + "common_bulk_remove_partial": "Removed {success} of {total} {resource}. {failed} failed.", "common_bulk_delete_success": "Successfully deleted {count} {resource}", "common_bulk_delete_failed": "Failed to delete {count} {resource}", + "common_bulk_delete_partial": "Deleted {success} of {total} {resource}. {failed} failed.", "_comment_common_confirmations": "=== COMMON - CONFIRMATION DIALOGS ===", "common_delete_title": "Delete {resource}", "common_delete_confirm": "Are you sure you want to delete {resource}? This action cannot be undone.", diff --git a/frontend/src/lib/components/arcane-table/arcane-table.svelte b/frontend/src/lib/components/arcane-table/arcane-table.svelte index ad57bc3272..f6b96f5ef5 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table.svelte +++ b/frontend/src/lib/components/arcane-table/arcane-table.svelte @@ -33,7 +33,7 @@ type BulkAction } from './arcane-table.types.svelte'; import type { Component } from 'svelte'; - import { extractPersistedPreferences, filterMapsEqual, toFilterMap } from './arcane-table.utils'; + import { extractPersistedPreferences, filterMapsEqual, fromFilterMap, toFilterMap } from './arcane-table.utils'; import ArcaneTablePagination from './arcane-table-pagination.svelte'; import ArcaneTableHeader from './arcane-table-header.svelte'; import ArcaneTableCell from './arcane-table-cell.svelte'; @@ -607,6 +607,19 @@ } }); + // Reflect externally-set requestOptions.filters (e.g. a clickable stat card applying + // a filter) back into the facet UI so the displayed filters match the active query. + // Only mutates local columnFilters — never triggers onRefresh — so it can't loop with + // the forward onColumnFiltersChange path. + $effect(() => { + const incoming = requestOptions?.filters; + const currentMap = untrack(() => toFilterMap(columnFilters)); + if (filterMapsEqual(incoming, currentMap)) return; + untrack(() => { + columnFilters = fromFilterMap(incoming); + }); + }); + // Track last persisted settings to prevent infinite loops let lastPersistedSettings: string | null = null; let persistTimeout: ReturnType | null = null; diff --git a/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts b/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts index fa5606fa66..1c8c12801c 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts +++ b/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts @@ -130,7 +130,7 @@ export function buildMobileVisibility(fields: FieldSpec[], persisted?: string[]) export type BulkAction = { id: string; label: string; - action: 'base' | 'start' | 'stop' | 'restart' | 'remove' | 'deploy' | 'redeploy' | 'up' | 'down'; + action: 'base' | 'start' | 'stop' | 'restart' | 'remove' | 'deploy' | 'redeploy' | 'up' | 'down' | 'prune'; onClick: (ids: string[]) => void; disabled?: boolean; disabledReason?: string; diff --git a/frontend/src/lib/components/arcane-table/arcane-table.utils.ts b/frontend/src/lib/components/arcane-table/arcane-table.utils.ts index eb26ef09b6..c7ee3fd663 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table.utils.ts +++ b/frontend/src/lib/components/arcane-table/arcane-table.utils.ts @@ -34,6 +34,23 @@ export function toFilterMap(filters: ColumnFiltersState): FilterMap { return out; } +/** + * Inverse of {@link toFilterMap}: rebuilds tanstack `ColumnFiltersState` from a + * request `FilterMap`. Used to reflect externally-set `requestOptions.filters` + * (e.g. a clickable stat card) back into the faceted-filter UI. Values are kept + * as-is so they match the facet option `value` types (array, boolean, string). + */ +export function fromFilterMap(map?: FilterMap): ColumnFiltersState { + if (!map) return []; + const out: ColumnFiltersState = []; + for (const [id, value] of Object.entries(map)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value) && value.length === 0) continue; + out.push({ id, value }); + } + return out; +} + export function filterMapsEqual(a?: FilterMap, b?: FilterMap): boolean { const keysA = Object.keys(a ?? {}); const keysB = Object.keys(b ?? {}); diff --git a/frontend/src/lib/components/copy-button.svelte b/frontend/src/lib/components/copy-button.svelte new file mode 100644 index 0000000000..9d3efc81fd --- /dev/null +++ b/frontend/src/lib/components/copy-button.svelte @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/lib/components/stat-card.svelte b/frontend/src/lib/components/stat-card.svelte index 9c1a465ec8..bbed1bf49a 100644 --- a/frontend/src/lib/components/stat-card.svelte +++ b/frontend/src/lib/components/stat-card.svelte @@ -12,6 +12,10 @@ subtitle?: string; class?: ClassValue; variant?: 'default' | 'mini'; + /** When provided (mini variant), the card becomes a button — e.g. to apply a table filter. */ + onclick?: () => void; + /** Highlights the mini card as the currently-applied filter. */ + active?: boolean; } let { @@ -22,22 +26,43 @@ bgColor = 'bg-primary/10', subtitle, class: className, - variant = 'default' + variant = 'default', + onclick, + active = false }: Props = $props(); +{#snippet miniContent()} + +
+ + {value} + + + {title} + +
+{/snippet} + {#if variant === 'mini'} -
- -
- - {value} - - - {title} - + {#if onclick} + + {:else} +
+ {@render miniContent()}
-
+ {/if} {:else}
+ import * as Empty from '$lib/components/ui/empty/index.js'; + import { ArcaneButton } from '$lib/components/arcane-button/index.js'; + import type { IconType } from '$lib/icons'; + import type { Snippet } from 'svelte'; + import type { ClassValue } from 'svelte/elements'; + + interface Props { + /** Optional icon rendered in the muted media box. */ + icon?: IconType; + title: string; + description?: string; + /** Renders a single primary action button when provided. */ + actionLabel?: string; + onAction?: () => void; + actionHref?: string; + class?: ClassValue; + /** Custom action content; overrides the actionLabel button when present. */ + children?: Snippet; + } + + let { icon: Icon, title, description, actionLabel, onAction, actionHref, class: className, children }: Props = $props(); + + + + + {#if Icon} + + + {/if} + {title} + {#if description} + {description} + {/if} + + {#if children} + + {@render children()} + + {:else if actionLabel} + + {#if actionHref} + + {:else} + + {/if} + + {/if} + diff --git a/frontend/src/lib/components/states/index.ts b/frontend/src/lib/components/states/index.ts new file mode 100644 index 0000000000..03fa3b00f1 --- /dev/null +++ b/frontend/src/lib/components/states/index.ts @@ -0,0 +1,2 @@ +export { default as EmptyState } from './empty-state.svelte'; +export { default as InlineError } from './inline-error.svelte'; diff --git a/frontend/src/lib/components/states/inline-error.svelte b/frontend/src/lib/components/states/inline-error.svelte new file mode 100644 index 0000000000..1703f926fd --- /dev/null +++ b/frontend/src/lib/components/states/inline-error.svelte @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/lib/layouts/resource-page-layout.svelte b/frontend/src/lib/layouts/resource-page-layout.svelte index 0c41528fb4..2b60e10345 100644 --- a/frontend/src/lib/layouts/resource-page-layout.svelte +++ b/frontend/src/lib/layouts/resource-page-layout.svelte @@ -10,6 +10,10 @@ iconColor?: string; bgColor?: string; class?: string; + /** When provided, the mini stat card becomes a clickable filter trigger. */ + onclick?: () => void; + /** Highlights the mini stat card as the currently-applied filter. */ + active?: boolean; } @@ -76,6 +80,8 @@ icon={card.icon} iconColor={card.iconColor} class={card.class} + onclick={card.onclick} + active={card.active} /> {/each}
diff --git a/frontend/src/lib/utils/bulk-actions.ts b/frontend/src/lib/utils/bulk-actions.ts new file mode 100644 index 0000000000..679bd8bb07 --- /dev/null +++ b/frontend/src/lib/utils/bulk-actions.ts @@ -0,0 +1,156 @@ +import { toast } from 'svelte-sonner'; +import { openConfirmDialog } from '$lib/components/confirm-dialog'; +import { tryCatch, type Result } from '$lib/utils/api'; +import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; + +/** + * Shared helpers for table bulk operations (start/stop/remove/prune/…). These + * consolidate the iterate-tally-toast-refresh-clear loop that was copy-pasted + * across every `*-table.svelte`. + */ + +export interface BulkOperationMessages { + /** Toast shown when every item succeeded. Receives the number that succeeded. */ + success: (count: number) => string; + /** Toast shown when some items succeeded and some failed. */ + partial: (success: number, total: number, failed: number) => string; + /** Toast shown when every item failed. */ + failure: () => string; +} + +export interface BulkOperationResult { + total: number; + success: number; + failed: number; +} + +export interface RunBulkOperationOptions { + /** The ids to operate on. A no-op when empty. */ + ids: string[]; + /** Runs the operation for a single id. May throw — failures are tallied. */ + run: (id: string) => Promise; + messages: BulkOperationMessages; + /** Toggle a loading flag around the whole run. */ + setLoading?: (loading: boolean) => void; + /** Called once after the run (and toast), regardless of outcome — e.g. to refresh data. */ + onComplete?: (result: BulkOperationResult) => void | Promise; + /** Clears the table selection after completion. Always called when there were ids. */ + clearSelection?: () => void; + /** Run operations one at a time instead of concurrently. Defaults to concurrent. */ + sequential?: boolean; +} + +/** + * Runs `run` for each id, tallies successes/failures, emits a single summary + * toast (success / partial / failure), then refreshes and clears the selection. + * The success toast links to the first resulting activity when one is present. + */ +export async function runBulkOperation({ + ids, + run, + messages, + setLoading, + onComplete, + clearSelection, + sequential = false +}: RunBulkOperationOptions): Promise { + const total = ids?.length ?? 0; + const result: BulkOperationResult = { total, success: 0, failed: 0 }; + if (total === 0) return result; + + let firstActivityId: string | undefined; + const tally = (outcome: Result) => { + if (outcome.error) { + result.failed += 1; + } else { + result.success += 1; + if (!firstActivityId) firstActivityId = extractActivityId(outcome.data); + } + }; + + setLoading?.(true); + try { + if (sequential) { + for (const id of ids) { + tally(await tryCatch(run(id))); + } + } else { + const outcomes = await Promise.all(ids.map((id) => tryCatch(run(id)))); + for (const outcome of outcomes) tally(outcome); + } + } finally { + setLoading?.(false); + } + + if (result.failed === 0) { + toast.success(messages.success(result.success), activityToastOptions(firstActivityId)); + } else if (result.success > 0) { + toast.warning(messages.partial(result.success, total, result.failed)); + } else { + toast.error(messages.failure()); + } + + await onComplete?.(result); + clearSelection?.(); + + return result; +} + +export interface BulkConfirmCheckbox { + id: string; + label: string; + initialState?: boolean; +} + +export interface BulkConfirmAndRunOptions extends Omit, 'run'> { + title: string; + message: string; + confirmLabel: string; + destructive?: boolean; + /** Optional confirm-dialog checkboxes (e.g. force / remove volumes). */ + checkboxes?: BulkConfirmCheckbox[]; + /** Runs the operation for a single id, given the resolved checkbox states. */ + run: (id: string, checkboxStates: Record) => Promise; +} + +/** + * Opens a confirm dialog (with optional checkboxes) and, on confirm, runs the + * bulk operation via {@link runBulkOperation}. A no-op when `ids` is empty. + */ +export function bulkConfirmAndRun({ + ids, + title, + message, + confirmLabel, + destructive = false, + checkboxes, + run, + messages, + setLoading, + onComplete, + clearSelection, + sequential +}: BulkConfirmAndRunOptions): void { + if (!ids || ids.length === 0) return; + + openConfirmDialog({ + title, + message, + checkboxes, + confirm: { + label: confirmLabel, + destructive, + action: async (checkboxStates) => { + await runBulkOperation({ + ids, + run: (id) => run(id, checkboxStates), + messages, + setLoading, + onComplete, + clearSelection, + sequential + }); + } + } + }); +} diff --git a/frontend/src/routes/(app)/containers/[containerId]/+error.svelte b/frontend/src/routes/(app)/containers/[containerId]/+error.svelte new file mode 100644 index 0000000000..5b4c41d448 --- /dev/null +++ b/frontend/src/routes/(app)/containers/[containerId]/+error.svelte @@ -0,0 +1,36 @@ + + +
+ + + + + + {status === 404 ? m.error_not_found() : m.error_generic()} + + + {message} + + + +
+ goto('/containers')} + /> +
+
+
+
diff --git a/frontend/src/routes/(app)/containers/container-table.actions.ts b/frontend/src/routes/(app)/containers/container-table.actions.ts index 6c40b304fb..f21132ca7d 100644 --- a/frontend/src/routes/(app)/containers/container-table.actions.ts +++ b/frontend/src/routes/(app)/containers/container-table.actions.ts @@ -5,6 +5,7 @@ import type { ContainerSummaryDto } from '$lib/types/docker'; import { handleApiResultWithCallbacks } from '$lib/utils/api'; import { tryCatch } from '$lib/utils/api'; import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; +import { bulkConfirmAndRun } from '$lib/utils/bulk-actions'; import { toast } from 'svelte-sonner'; import { getContainerDisplayName, type ActionStatus } from './container-table.helpers'; @@ -210,37 +211,24 @@ export function createContainerActions({ }); } - async function runBulkAction(ids: string[], config: BulkActionConfig) { - if (!ids || ids.length === 0) return; - - openConfirmDialog({ + function runBulkAction(ids: string[], config: BulkActionConfig) { + bulkConfirmAndRun({ + ids, title: config.title(ids.length), message: config.message(ids.length), - confirm: { - label: config.label, - destructive: config.destructive ?? false, - action: async () => { - isBulkLoading[config.loadingKey] = true; - - const results = await Promise.allSettled(ids.map((id) => config.run(id))); - - const successCount = results.filter((result) => result.status === 'fulfilled').length; - const failureCount = results.length - successCount; - - isBulkLoading[config.loadingKey] = false; - - if (successCount === ids.length) { - toast.success(config.success(successCount)); - } else if (successCount > 0) { - toast.warning(config.partial(successCount, ids.length, failureCount)); - } else { - toast.error(config.failure()); - } - - await reloadContainers(); - setSelectedIds([]); - } - } + confirmLabel: config.label, + destructive: config.destructive ?? false, + run: (id) => config.run(id), + messages: { + success: config.success, + partial: config.partial, + failure: config.failure + }, + setLoading: (loading) => { + isBulkLoading[config.loadingKey] = loading; + }, + onComplete: () => reloadContainers(), + clearSelection: () => setSelectedIds([]) }); } @@ -283,51 +271,29 @@ export function createContainerActions({ }); } - async function handleBulkRemove(ids: string[]) { - if (!ids || ids.length === 0) return; - - openConfirmDialog({ + function handleBulkRemove(ids: string[]) { + bulkConfirmAndRun({ + ids, title: m.containers_bulk_remove_confirm_title({ count: ids.length }), message: m.containers_bulk_remove_confirm_message({ count: ids.length }), + confirmLabel: m.common_remove(), + destructive: true, checkboxes: [ - { - id: 'force', - label: m.containers_remove_force_label(), - initialState: false - }, - { - id: 'volumes', - label: m.containers_remove_volumes_label(), - initialState: false - } + { id: 'force', label: m.containers_remove_force_label(), initialState: false }, + { id: 'volumes', label: m.containers_remove_volumes_label(), initialState: false } ], - confirm: { - label: m.common_remove(), - destructive: true, - action: async (checkboxStates) => { - const force = !!checkboxStates['force']; - const volumes = !!checkboxStates['volumes']; - isBulkLoading.remove = true; - - const results = await Promise.allSettled(ids.map((id) => containerService.deleteContainer(id, { force, volumes }))); - - const successCount = results.filter((result) => result.status === 'fulfilled').length; - const failureCount = results.length - successCount; - - isBulkLoading.remove = false; - - if (successCount === ids.length) { - toast.success(m.containers_bulk_remove_success({ count: successCount })); - } else if (successCount > 0) { - toast.warning(m.containers_bulk_remove_partial({ success: successCount, total: ids.length, failed: failureCount })); - } else { - toast.error(m.containers_remove_failed()); - } - - await reloadContainers(); - setSelectedIds([]); - } - } + run: (id, checkboxStates) => + containerService.deleteContainer(id, { force: !!checkboxStates['force'], volumes: !!checkboxStates['volumes'] }), + messages: { + success: (count) => m.containers_bulk_remove_success({ count }), + partial: (success, total, failed) => m.containers_bulk_remove_partial({ success, total, failed }), + failure: () => m.containers_remove_failed() + }, + setLoading: (loading) => { + isBulkLoading.remove = loading; + }, + onComplete: () => reloadContainers(), + clearSelection: () => setSelectedIds([]) }); } diff --git a/frontend/src/routes/(app)/images/[imageId]/+error.svelte b/frontend/src/routes/(app)/images/[imageId]/+error.svelte new file mode 100644 index 0000000000..e952991cc3 --- /dev/null +++ b/frontend/src/routes/(app)/images/[imageId]/+error.svelte @@ -0,0 +1,36 @@ + + +
+ + + + + + {status === 404 ? m.error_not_found() : m.error_generic()} + + + {message} + + + +
+ goto('/images')} + /> +
+
+
+
diff --git a/frontend/src/routes/(app)/images/image-table.svelte b/frontend/src/routes/(app)/images/image-table.svelte index 32fe28f8e8..0ef76791e1 100644 --- a/frontend/src/routes/(app)/images/image-table.svelte +++ b/frontend/src/routes/(app)/images/image-table.svelte @@ -28,6 +28,7 @@ import { environmentStore } from '$lib/stores/environment.store.svelte'; import { hasPermission } from '$lib/utils/auth'; import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; + import { bulkConfirmAndRun } from '$lib/utils/bulk-actions'; import { DownloadIcon, @@ -81,57 +82,25 @@ images = await imageService.getImages(options); } - async function handleDeleteSelected(ids: string[]) { - if (!ids || ids.length === 0) return; - - openConfirmDialog({ + function handleDeleteSelected(ids: string[]) { + bulkConfirmAndRun({ + ids, title: m.images_remove_selected_title({ count: ids.length }), message: m.images_remove_selected_message({ count: ids.length }), - checkboxes: [ - { - id: 'force', - label: m.images_remove_force_label(), - initialState: false - } - ], - confirm: { - label: m.common_remove(), - destructive: true, - action: async (checkboxStates) => { - const force = !!checkboxStates['force']; - isLoading.removing = true; - let successCount = 0; - let failureCount = 0; - - for (const id of ids) { - const result = await tryCatch(imageService.deleteImage(id, { force })); - handleApiResultWithCallbacks({ - result, - message: m.images_remove_failed(), - setLoadingState: () => {}, - onSuccess: () => { - successCount++; - } - }); - if (result.error) failureCount++; - } - - isLoading.removing = false; - - if (successCount > 0) { - const msg = - successCount === 1 ? m.images_remove_success_one() : m.images_remove_success_many({ count: successCount }); - toast.success(msg); - await refreshImages(); - } - if (failureCount > 0) { - const msg = failureCount === 1 ? m.images_remove_failed_one() : m.images_remove_failed_many({ count: failureCount }); - toast.error(msg); - } - - selectedIds = []; - } - } + confirmLabel: m.common_remove(), + destructive: true, + checkboxes: [{ id: 'force', label: m.images_remove_force_label(), initialState: false }], + run: (id, checkboxStates) => imageService.deleteImage(id, { force: !!checkboxStates['force'] }), + messages: { + success: (count) => (count === 1 ? m.images_remove_success_one() : m.images_remove_success_many({ count })), + partial: (success, total, failed) => m.common_bulk_remove_partial({ success, total, failed, resource: m.images_title() }), + failure: () => (ids.length === 1 ? m.images_remove_failed_one() : m.images_remove_failed_many({ count: ids.length })) + }, + setLoading: (loading) => (isLoading.removing = loading), + onComplete: async (result) => { + if (result.success > 0) await refreshImages(); + }, + clearSelection: () => (selectedIds = []) }); } diff --git a/frontend/src/routes/(app)/networks/[networkId]/+error.svelte b/frontend/src/routes/(app)/networks/[networkId]/+error.svelte new file mode 100644 index 0000000000..07b158eff2 --- /dev/null +++ b/frontend/src/routes/(app)/networks/[networkId]/+error.svelte @@ -0,0 +1,36 @@ + + +
+ + + + + + {status === 404 ? m.error_not_found() : m.error_generic()} + + + {message} + + + +
+ goto('/networks')} + /> +
+
+
+
diff --git a/frontend/src/routes/(app)/networks/network-table.svelte b/frontend/src/routes/(app)/networks/network-table.svelte index fe58e506b8..277d3e21d8 100644 --- a/frontend/src/routes/(app)/networks/network-table.svelte +++ b/frontend/src/routes/(app)/networks/network-table.svelte @@ -18,6 +18,7 @@ import { networkService } from '$lib/services/network-service'; import { NetworksIcon, GlobeIcon, InspectIcon, TrashIcon, EllipsisIcon } from '$lib/icons'; import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; + import { bulkConfirmAndRun } from '$lib/utils/bulk-actions'; type FieldVisibility = Record; @@ -81,7 +82,7 @@ }); } - async function handleDeleteSelectedNetworks(ids: string[]) { + function handleDeleteSelectedNetworks(ids: string[]) { const selectedNetworkList = networks.data.filter((n) => ids.includes(n.id)); const defaultNetworks = selectedNetworkList.filter((n) => n.isDefault); @@ -91,46 +92,24 @@ return; } - openConfirmDialog({ + bulkConfirmAndRun({ + ids, title: m.networks_delete_selected_title({ count: ids.length }), message: m.networks_delete_selected_message({ count: ids.length }), - confirm: { - label: m.common_delete(), - destructive: true, - action: async () => { - isLoading.remove = true; - let successCount = 0; - let failureCount = 0; - - for (const networkId of ids) { - const network = networks.data.find((n) => n.id === networkId); - if (!network) continue; - - const result = await tryCatch(networkService.deleteNetwork(networkId)); - if (result.error) { - failureCount++; - toast.error( - m.common_delete_failed({ resource: `${m.resource_network()} "${network.name ?? m.common_unknown()}"` }) - ); - } else { - successCount++; - toast.success( - m.common_delete_success({ - resource: `${m.resource_network()} "${network.name ?? m.common_unknown()}"` - }), - activityToastOptions(extractActivityId(result.data)) - ); - } - } - - isLoading.remove = false; - - if (successCount > 0) { - await refreshNetworks(); - } - selectedIds = []; - } - } + confirmLabel: m.common_delete(), + destructive: true, + run: (id) => networkService.deleteNetwork(id), + messages: { + success: (count) => m.common_bulk_delete_success({ count, resource: m.networks_title() }), + partial: (success, total, failed) => + m.common_bulk_delete_partial({ success, total, failed, resource: m.networks_title() }), + failure: () => m.common_bulk_delete_failed({ count: ids.length, resource: m.networks_title() }) + }, + setLoading: (loading) => (isLoading.remove = loading), + onComplete: async (result) => { + if (result.success > 0) await refreshNetworks(); + }, + clearSelection: () => (selectedIds = []) }); } diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index df11fe8f57..d284212fe3 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -28,6 +28,7 @@ import * as InputGroup from '$lib/components/ui/input-group/index.js'; import { getSettingsSubpageUrlsInNavOrder } from '$lib/config/navigation-config'; import HeaderCard from '$lib/components/header-card.svelte'; + import { Spinner } from '$lib/components/ui/spinner/index.js'; let {}: PageProps = $props(); @@ -219,9 +220,7 @@ {#if isSearching}
-
+

{m.settings_searching()}

{:else if searchResults.length === 0} diff --git a/frontend/src/routes/(app)/volumes/[volumeName]/+error.svelte b/frontend/src/routes/(app)/volumes/[volumeName]/+error.svelte new file mode 100644 index 0000000000..abd3e52a38 --- /dev/null +++ b/frontend/src/routes/(app)/volumes/[volumeName]/+error.svelte @@ -0,0 +1,36 @@ + + +
+ + + + + + {status === 404 ? m.error_not_found() : m.error_generic()} + + + {message} + + + +
+ goto('/volumes')} + /> +
+
+
+
diff --git a/frontend/src/routes/(app)/volumes/volume-table.svelte b/frontend/src/routes/(app)/volumes/volume-table.svelte index e763c41c51..65ee33dd3d 100644 --- a/frontend/src/routes/(app)/volumes/volume-table.svelte +++ b/frontend/src/routes/(app)/volumes/volume-table.svelte @@ -25,6 +25,7 @@ import { environmentStore } from '$lib/stores/environment.store.svelte'; import { hasPermission } from '$lib/utils/auth'; import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; + import { bulkConfirmAndRun } from '$lib/utils/bulk-actions'; let { volumes = $bindable(), @@ -128,56 +129,28 @@ }); } - async function handleDeleteSelected(ids: string[]) { - if (!ids?.length) return; - - openConfirmDialog({ - title: m.volumes_remove_selected_title({ count: ids.length }), - message: m.volumes_remove_selected_message({ count: ids.length }), - confirm: { - label: m.common_remove(), - destructive: true, - action: async () => { - isLoading.removing = true; - let successCount = 0; - let failureCount = 0; - - const idToName = new Map(volumes.data.map((v) => [v.id, v.name] as const)); - const idsToDelete = ids.filter((id) => !isBackupVolumeName(idToName.get(id))); - if (!idsToDelete.length) { - isLoading.removing = false; - selectedIds = []; - return; - } - - for (const id of idsToDelete) { - const name = idToName.get(id); - const safeName = name?.trim() || m.common_unknown(); - const result = await tryCatch(volumeService.deleteVolume(safeName)); - handleApiResultWithCallbacks({ - result, - message: m.common_remove_failed({ resource: `${m.resource_volume()} "${safeName}"` }), - setLoadingState: () => {}, - onSuccess: (_data) => { - successCount += 1; - } - }); - if (result.error) failureCount += 1; - } - - isLoading.removing = false; - if (successCount > 0) { - const successMsg = m.common_bulk_remove_success({ count: successCount, resource: m.volumes_title() }); - toast.success(successMsg); - await refreshVolumes(); - } - if (failureCount > 0) { - const failureMsg = m.common_bulk_remove_failed({ count: failureCount, resource: m.volumes_title() }); - toast.error(failureMsg); - } - selectedIds = []; - } - } + function handleDeleteSelected(ids: string[]) { + const idToName = new Map(volumes.data.map((v) => [v.id, v.name] as const)); + const idsToDelete = ids.filter((id) => !isBackupVolumeName(idToName.get(id))); + + bulkConfirmAndRun({ + ids: idsToDelete, + title: m.volumes_remove_selected_title({ count: idsToDelete.length }), + message: m.volumes_remove_selected_message({ count: idsToDelete.length }), + confirmLabel: m.common_remove(), + destructive: true, + run: (id) => volumeService.deleteVolume(idToName.get(id)?.trim() || m.common_unknown()), + messages: { + success: (count) => m.common_bulk_remove_success({ count, resource: m.volumes_title() }), + partial: (success, total, failed) => + m.common_bulk_remove_partial({ success, total, failed, resource: m.volumes_title() }), + failure: () => m.common_bulk_remove_failed({ count: idsToDelete.length, resource: m.volumes_title() }) + }, + setLoading: (loading) => (isLoading.removing = loading), + onComplete: async (result) => { + if (result.success > 0) await refreshVolumes(); + }, + clearSelection: () => (selectedIds = []) }); } From 5422122a53f02dc2db649e448aa52aa9d4afc1f6 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 29 May 2026 08:46:58 -0500 Subject: [PATCH 18/40] refactor: modernize and refactor deduped logic (#2759) --- .github/.golangci.yml | 1 + AGENTS.md | 45 +- backend/api/handlers/activities.go | 6 +- backend/api/handlers/apikeys.go | 28 +- backend/api/handlers/containers.go | 106 ++- backend/api/handlers/edge_mtls_ca.go | 4 +- backend/api/handlers/environments.go | 12 +- backend/api/handlers/git_repositories.go | 189 +--- backend/api/handlers/gitops_syncs.go | 194 +--- backend/api/handlers/helpers.go | 102 +- backend/api/handlers/images.go | 11 +- backend/api/handlers/projects.go | 198 ++-- backend/api/handlers/settings_test.go | 3 +- backend/api/handlers/volumes.go | 68 +- backend/go.mod | 14 +- backend/go.sum | 19 +- backend/internal/bootstrap/edge_bootstrap.go | 6 +- backend/internal/config/config.go | 11 +- .../middleware/environment_middleware.go | 3 +- backend/internal/services/activity_service.go | 6 +- .../services/activity_service_test.go | 12 +- backend/internal/services/api_key_service.go | 3 +- .../internal/services/api_key_service_test.go | 3 +- backend/internal/services/auth_service.go | 3 +- backend/internal/services/build_service.go | 6 +- .../services/container_registry_service.go | 18 +- .../internal/services/container_service.go | 102 +- .../services/container_service_test.go | 198 ---- .../internal/services/dashboard_service.go | 6 +- .../internal/services/environment_service.go | 6 +- .../services/environment_service_test.go | 7 +- .../services/git_repository_service.go | 18 +- .../services/git_repository_service_test.go | 18 +- .../internal/services/gitops_sync_service.go | 6 +- backend/internal/services/image_service.go | 7 +- backend/internal/services/job_service_test.go | 9 +- backend/internal/services/log_stream_util.go | 112 --- .../internal/services/notification_service.go | 901 ++++++------------ backend/internal/services/project_service.go | 89 +- .../internal/services/project_service_test.go | 6 +- backend/internal/services/role_service.go | 3 +- .../services/settings_service_test.go | 9 +- backend/internal/services/swarm_service.go | 4 +- backend/internal/services/system_service.go | 71 +- backend/internal/services/updater_service.go | 50 +- .../services/vulnerability_service.go | 6 +- backend/pkg/dockerutil/compose_labels.go | 22 + backend/pkg/dockerutil/compose_labels_test.go | 24 + backend/pkg/dockerutil/container_names.go | 30 + .../pkg/dockerutil/container_names_test.go | 71 ++ backend/pkg/dockerutil/log_stream.go | 170 ++++ backend/pkg/dockerutil/log_stream_test.go | 198 ++++ backend/pkg/libarcane/activity/writer.go | 9 +- backend/pkg/libarcane/edge/client.go | 53 +- .../pkg/libarcane/edge/client_grpc_test.go | 12 +- .../libarcane/imageupdate/registry_http.go | 3 +- backend/pkg/libarcane/imageupdate/sorter.go | 15 +- .../pkg/libarcane/imageupdate/sorter_test.go | 37 - .../libarcane/swarm/stack_deploy_engine.go | 216 +++-- backend/pkg/pagination/db_utils.go | 34 + backend/pkg/projects/fs_templates.go | 3 +- backend/pkg/projects/image_refs.go | 65 ++ backend/pkg/scheduler/auto_heal_job.go | 13 +- backend/pkg/utils/httpx/request.go | 11 + backend/pkg/utils/notifications/messages.go | 12 +- cli/go.mod | 24 +- cli/go.sum | 54 +- cli/internal/logger/logger.go | 2 +- cli/pkg/admin/oidcmappings/cmd.go | 6 +- cli/pkg/admin/roles/cmd.go | 3 +- cli/pkg/containers/cmd.go | 232 ++--- cli/pkg/projects/cmd.go | 167 ++-- cli/pkg/root.go | 2 +- cli/pkg/settings/cmd.go | 117 +-- cli/pkg/volumes/cmd.go | 104 +- go.work.sum | 4 + types/go.mod | 8 +- types/go.sum | 18 +- 78 files changed, 1957 insertions(+), 2481 deletions(-) delete mode 100644 backend/internal/services/log_stream_util.go create mode 100644 backend/pkg/dockerutil/compose_labels.go create mode 100644 backend/pkg/dockerutil/compose_labels_test.go create mode 100644 backend/pkg/dockerutil/container_names.go create mode 100644 backend/pkg/dockerutil/container_names_test.go create mode 100644 backend/pkg/dockerutil/log_stream.go create mode 100644 backend/pkg/dockerutil/log_stream_test.go create mode 100644 backend/pkg/projects/image_refs.go diff --git a/.github/.golangci.yml b/.github/.golangci.yml index b3703c6631..dd6d790e9e 100644 --- a/.github/.golangci.yml +++ b/.github/.golangci.yml @@ -11,6 +11,7 @@ linters: - bodyclose - contextcheck - copyloopvar + - dupl - durationcheck - errcheck - errchkjson diff --git a/AGENTS.md b/AGENTS.md index f702551369..deb1403f63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,16 @@ internal/ ├── models/ # GORM database models (include BaseModel for UUID, timestamps) └── services/ # Business logic — *_service.go files contain domain logic pkg/ -├── libarcane/ # Core reusable backend/domain libraries -├── projects/ # Compose project parsing and filesystem helpers +├── authz/ # Permission taxonomy and authorization primitives +├── dockerutil/ # Low-level Docker helpers (container names, compose labels, log streaming, mounts/volumes/networks) +├── fswatch/ # Filesystem watcher that detects project file changes +├── gitutil/ # Git client for GitOps (clone, branches, file tree, read) +├── libarcane/ # Core reusable backend/domain libraries (compose deploy, edge, image update, build, swarm) +├── pagination/ # Search/filter/sort/pagination helpers (DB and in-memory) +├── projects/ # Compose project parsing, env, includes, discovery, filesystem helpers +├── remenv/ # Remote-environment request/response transport client (agent/edge) ├── scheduler/ # Cron-backed background jobs -├── pagination/ # Search/filter/sort/pagination helpers -└── utils/ # Shared helper utilities +└── utils/ # Shared helper utilities (strings, cache, ptr, httpx, mapper) resources/ # migrations, images, fonts, email templates ``` @@ -57,6 +62,14 @@ resources/ # migrations, images, fonts, email templates - Use `slog` for structured logging with context - Error wrapping: `fmt.Errorf("context: %w", err)` +**Reuse `pkg/` helpers — do not duplicate or wrap:** Before writing inline logic, check `pkg/` for an existing helper: + +- Docker primitives → `pkg/dockerutil`: container display names (`ContainerNameFromNames`, `ContainerSummaryName`), Compose labels (`ComposeProjectLabel`/`ComposeServiceLabel`, keyed by `ComposeProjectLabelKey`/`ComposeServiceLabelKey`), log streaming (`StreamContainerLogs`/`StreamMultiplexedLogs`/`ReadAllLogs`), and mount/volume/network/cgroup utilities. +- Compose parsing → `pkg/projects`: image-ref extraction (`ImageRefsFromComposeServices`/`ImageRefsFromComposeConfigs`/`ImageRefsFromRuntimeServices`), plus compose load, env state, includes, and project discovery. +- Listing endpoints → `pkg/pagination` (`SearchOrderAndPaginate`, `PaginateAndSortDB`); shared string/ptr/cache helpers → `pkg/utils`. + +Call middleware/library functions directly at the call site. Do not add thin pass-through helpers or stubs that only forward to a single underlying call. + ### API Layer (`backend/api/`) ``` @@ -379,6 +392,18 @@ newScheduler.RegisterJob(myJob) **Note:** Cron uses 6 fields (with seconds): `"0 0 * * * *"` = every hour at :00:00 +## Image & Container Update Logic + +Update detection and application are split across layers — change behavior at the matching layer: + +- **`backend/pkg/libarcane/imageupdate/`** — comparison primitives: `digest.go` (DigestChecker: local vs remote digest), `registry_http.go` (registry digest / rate-limit queries, reference parsing), `labels.go` (Arcane/Compose label checks, self-redeploy guards), `sorter.go` (dependency-ordered container restart). +- **`backend/internal/services/image_update_service.go`** — checks for updates and persists update records (`CheckImageUpdate`, `CheckMultipleImages`, `CheckAllImages`, `GetUpdateSummary`, `MarkImageRefUpToDateAfterPull`). +- **`backend/internal/services/updater_service.go`** — applies updates to running containers (`ApplyPending`, `UpdateSingleContainer`), with status/history. +- **`backend/api/handlers/image_updates.go`** — check/query API (`check-image-update`, `check-all-images`, `get-update-summary`); **`updater.go`** — apply API (`run-updater`, `update-container`, status/history). +- **`backend/pkg/scheduler/`** — background drivers: `image_polling_job.go` (periodic checks), `auto_update_job.go` (auto-apply), `auto_heal_job.go` (restart unhealthy containers). + +Project image references are cached on the project row (`image_refs_json`, extracted via the `pkg/projects` `ImageRefsFrom*` helpers); per-project update summaries are assembled in `project_service.go`. + ## Database Patterns (GORM) ### BaseModel @@ -431,9 +456,9 @@ Edge agents connect outbound to a central manager via WebSocket or gRPC tunnel, **Configuration** (agent side): ```bash -ARCANE_EDGE_AGENT=true -ARCANE_MANAGER_API_URL=https://manager.example.com -ARCANE_AGENT_TOKEN= +EDGE_AGENT=true +MANAGER_API_URL=https://manager.example.com +AGENT_TOKEN= ``` **Message types** (see [tunnel.go](backend/pkg/libarcane/edge/tunnel.go)): @@ -449,11 +474,11 @@ Direct agents are passive: the agent runs an HTTP server on TCP 3553 and the man **Configuration** (agent side): ```bash -ARCANE_AGENT_MODE=true -ARCANE_AGENT_TOKEN= +AGENT_MODE=true +AGENT_TOKEN= ``` -`ARCANE_MANAGER_API_URL` is **not** used in Direct mode — the agent never dials out. Pairing completes when the manager's periodic health-check (every 2 min by default) reaches the agent. +`MANAGER_API_URL` is **not** used in Direct mode — the agent never dials out. Pairing completes when the manager's periodic health-check (every 2 min by default) reaches the agent. **When implementing agent features:** diff --git a/backend/api/handlers/activities.go b/backend/api/handlers/activities.go index c7a7185b19..c4be84a162 100644 --- a/backend/api/handlers/activities.go +++ b/backend/api/handlers/activities.go @@ -16,6 +16,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/types/activity" "github.com/getarcaneapp/arcane/types/base" "gorm.io/gorm" @@ -227,10 +228,7 @@ func (h *ActivityHandler) StreamActivities(ctx context.Context, input *StreamAct return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // streaming work must use humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) writer := humaCtx.BodyWriter() encoder := json.NewEncoder(writer) diff --git a/backend/api/handlers/apikeys.go b/backend/api/handlers/apikeys.go index 5f88573fb6..c9e2148f6a 100644 --- a/backend/api/handlers/apikeys.go +++ b/backend/api/handlers/apikeys.go @@ -238,29 +238,7 @@ func (h *ApiKeyHandler) ListApiKeys(ctx context.Context, input *ListApiKeysInput // CreateApiKey creates a new API key. func (h *ApiKeyHandler) CreateApiKey(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { - if h.apiKeyService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) - } - - apiKey, err := h.apiKeyService.CreateApiKey(ctx, user.ID, input.Body) - if err != nil { - if errors.Is(err, services.ErrApiKeyPermissionEscalation) { - return nil, huma.Error403Forbidden(err.Error()) - } - return nil, huma.Error500InternalServerError((&common.ApiKeyCreationError{Err: err}).Error()) - } - - return &CreateApiKeyOutput{ - Body: base.ApiResponse[apikey.ApiKeyCreatedDto]{ - Success: true, - Data: *apiKey, - }, - }, nil + return h.createCurrentUserApiKeyInternal(ctx, input) } // GetApiKey returns details of a specific API key. @@ -367,6 +345,10 @@ func (h *ApiKeyHandler) ListMyApiKeys(ctx context.Context, input *struct{}) (*Li // CreateMyApiKey creates a new API key owned by the current user (self-service). func (h *ApiKeyHandler) CreateMyApiKey(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { + return h.createCurrentUserApiKeyInternal(ctx, input) +} + +func (h *ApiKeyHandler) createCurrentUserApiKeyInternal(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { if h.apiKeyService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/containers.go b/backend/api/handlers/containers.go index db8d0d62d8..f6d577b14a 100644 --- a/backend/api/handlers/containers.go +++ b/backend/api/handlers/containers.go @@ -615,58 +615,64 @@ func (h *ContainerHandler) GetContainer(ctx context.Context, input *GetContainer } func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if h.containerService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized("not authenticated") - } - - runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID}) - if err := h.containerService.StartContainer(runtimeCtx, input.ContainerID, *user); err != nil { - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container started", err) - return nil, huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) - } - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container started", nil) - - return &ContainerActionOutput{ - Body: ContainerActionResponse{ - Success: true, - Data: base.MessageResponse{Message: "Container started successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, + return h.runContainerActionInternal(ctx, input, containerActionConfigInternal{ + ActivityType: models.ActivityTypeContainerStart, + Step: "Starting container", + StartMessage: "Container start requested", + CompleteMessage: "Container started", + SuccessMessage: "Container started successfully", + Action: func(runtimeCtx context.Context, containerID string, user models.User) error { + return h.containerService.StartContainer(runtimeCtx, containerID, user) }, - }, nil + Error: func(err error) error { + return huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) + }, + }) } func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if h.containerService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized("not authenticated") - } - - runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID}) - if err := h.containerService.StopContainer(runtimeCtx, input.ContainerID, *user); err != nil { - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container stopped", err) - return nil, huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) - } - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container stopped", nil) - - return &ContainerActionOutput{ - Body: ContainerActionResponse{ - Success: true, - Data: base.MessageResponse{Message: "Container stopped successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, + return h.runContainerActionInternal(ctx, input, containerActionConfigInternal{ + ActivityType: models.ActivityTypeContainerStop, + Step: "Stopping container", + StartMessage: "Container stop requested", + CompleteMessage: "Container stopped", + SuccessMessage: "Container stopped successfully", + Action: func(runtimeCtx context.Context, containerID string, user models.User) error { + return h.containerService.StopContainer(runtimeCtx, containerID, user) }, - }, nil + Error: func(err error) error { + return huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) + }, + }) } func (h *ContainerHandler) RestartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { + return h.runContainerActionInternal(ctx, input, containerActionConfigInternal{ + ActivityType: models.ActivityTypeContainerRestart, + Step: "Restarting container", + StartMessage: "Container restart requested", + CompleteMessage: "Container restarted", + SuccessMessage: "Container restarted successfully", + Action: func(runtimeCtx context.Context, containerID string, user models.User) error { + return h.containerService.RestartContainer(runtimeCtx, containerID, user) + }, + Error: func(err error) error { + return huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) + }, + }) +} + +type containerActionConfigInternal struct { + ActivityType models.ActivityType + Step string + StartMessage string + CompleteMessage string + SuccessMessage string + Action func(context.Context, string, models.User) error + Error func(error) error +} + +func (h *ContainerHandler) runContainerActionInternal(ctx context.Context, input *ContainerActionInput, cfg containerActionConfigInternal) (*ContainerActionOutput, error) { if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -677,17 +683,17 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID}) - if err := h.containerService.RestartContainer(runtimeCtx, input.ContainerID, *user); err != nil { - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container restarted", err) - return nil, huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, cfg.ActivityType, "container", input.ContainerID, input.ContainerID, user, cfg.Step, cfg.StartMessage, models.JSON{"containerID": input.ContainerID}) + if err := cfg.Action(runtimeCtx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.CompleteMessage, err) + return nil, cfg.Error(err) } - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container restarted", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.CompleteMessage, nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container restarted successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, + Data: base.MessageResponse{Message: cfg.SuccessMessage, ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } diff --git a/backend/api/handlers/edge_mtls_ca.go b/backend/api/handlers/edge_mtls_ca.go index dbc595ce89..1ad9aaa546 100644 --- a/backend/api/handlers/edge_mtls_ca.go +++ b/backend/api/handlers/edge_mtls_ca.go @@ -93,11 +93,9 @@ func readGeneratedEdgeMTLSCertificateInfoInternal(cfg *config.Config, envID stri expiresAt := cert.NotAfter.UTC() now := time.Now().UTC() remaining := expiresAt.Sub(now) - daysRemaining := edgeMTLSCertificateDaysRemainingInternal(now, expiresAt) - info := &typesenvironment.EdgeMTLSCertificate{ ExpiresAt: &expiresAt, - DaysRemaining: &daysRemaining, + DaysRemaining: new(edgeMTLSCertificateDaysRemainingInternal(now, expiresAt)), Expired: now.After(expiresAt), ExpiringSoon: now.Before(expiresAt) && remaining <= edgeMTLSCertificateExpiryWarningWindow, } diff --git a/backend/api/handlers/environments.go b/backend/api/handlers/environments.go index 3cc434763c..f7af5b6735 100644 --- a/backend/api/handlers/environments.go +++ b/backend/api/handlers/environments.go @@ -1387,10 +1387,8 @@ func (h *EnvironmentHandler) logMTLSAuditEvent(ctx context.Context, env *models. user, _ := humamw.GetCurrentUserFromContext(ctx) var userID, username *string if user != nil { - uid := user.ID - uname := user.Username - userID = &uid - username = &uname + userID = new(user.ID) + username = new(user.Username) } if extra == nil { @@ -1411,11 +1409,9 @@ func (h *EnvironmentHandler) logMTLSAuditEvent(ctx context.Context, env *models. } if env != nil { envID := env.ID - envName := env.Name - resourceType := "environment" - req.ResourceType = &resourceType + req.ResourceType = new("environment") req.ResourceID = &envID - req.ResourceName = &envName + req.ResourceName = new(env.Name) req.EnvironmentID = &envID } diff --git a/backend/api/handlers/git_repositories.go b/backend/api/handlers/git_repositories.go index 8aa8f16a85..c77860f27f 100644 --- a/backend/api/handlers/git_repositories.go +++ b/backend/api/handlers/git_repositories.go @@ -4,12 +4,10 @@ import ( "context" "github.com/danielgtaylor/huma/v2" - humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/gitops" ) @@ -118,131 +116,15 @@ type SyncGitRepositoriesOutput struct { func RegisterGitRepositories(api huma.API, repoService *services.GitRepositoryService) { h := &GitRepositoryHandler{repoService: repoService} - huma.Register(api, huma.Operation{ - OperationID: "listGitRepositories", - Method: "GET", - Path: "/customize/git-repositories", - Summary: "List git repositories", - Description: "Get a paginated list of git repositories", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposList), - }, h.ListRepositories) - - huma.Register(api, huma.Operation{ - OperationID: "createGitRepository", - Method: "POST", - Path: "/customize/git-repositories", - Summary: "Create a git repository", - Description: "Create a new git repository configuration", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposCreate), - }, h.CreateRepository) - - huma.Register(api, huma.Operation{ - OperationID: "getGitRepository", - Method: "GET", - Path: "/customize/git-repositories/{id}", - Summary: "Get a git repository", - Description: "Get a git repository by ID", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposRead), - }, h.GetRepository) - - huma.Register(api, huma.Operation{ - OperationID: "updateGitRepository", - Method: "PUT", - Path: "/customize/git-repositories/{id}", - Summary: "Update a git repository", - Description: "Update an existing git repository configuration", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposUpdate), - }, h.UpdateRepository) - - huma.Register(api, huma.Operation{ - OperationID: "deleteGitRepository", - Method: "DELETE", - Path: "/customize/git-repositories/{id}", - Summary: "Delete a git repository", - Description: "Delete a git repository configuration by ID", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposDelete), - }, h.DeleteRepository) - - huma.Register(api, huma.Operation{ - OperationID: "testGitRepository", - Method: "POST", - Path: "/customize/git-repositories/{id}/test", - Summary: "Test a git repository", - Description: "Test connectivity and authentication to a git repository", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposTest), - }, h.TestRepository) - - huma.Register(api, huma.Operation{ - OperationID: "listGitRepositoryBranches", - Method: "GET", - Path: "/customize/git-repositories/{id}/branches", - Summary: "List repository branches", - Description: "Get all branches from a git repository with default branch detection", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposRead), - }, h.ListBranches) - - huma.Register(api, huma.Operation{ - OperationID: "browseGitRepositoryFiles", - Method: "GET", - Path: "/customize/git-repositories/{id}/files", - Summary: "Browse repository files", - Description: "Browse files and directories in a git repository", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposRead), - }, h.BrowseFiles) - - huma.Register(api, huma.Operation{ - OperationID: "syncGitRepositories", - Method: "POST", - Path: "/git-repositories/sync", - Summary: "Sync git repositories", - Description: "Sync git repositories from a manager to this agent instance", - Tags: []string{"Git Repositories"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitReposSync), - }, h.SyncRepositories) + registerCustomizeSecuredInternal(api, "listGitRepositories", "GET", "/customize/git-repositories", "List git repositories", "Get a paginated list of git repositories", authz.PermGitReposList, h.ListRepositories) + registerCustomizeSecuredInternal(api, "createGitRepository", "POST", "/customize/git-repositories", "Create a git repository", "Create a new git repository configuration", authz.PermGitReposCreate, h.CreateRepository) + registerCustomizeSecuredInternal(api, "getGitRepository", "GET", "/customize/git-repositories/{id}", "Get a git repository", "Get a git repository by ID", authz.PermGitReposRead, h.GetRepository) + registerCustomizeSecuredInternal(api, "updateGitRepository", "PUT", "/customize/git-repositories/{id}", "Update a git repository", "Update an existing git repository configuration", authz.PermGitReposUpdate, h.UpdateRepository) + registerCustomizeSecuredInternal(api, "deleteGitRepository", "DELETE", "/customize/git-repositories/{id}", "Delete a git repository", "Delete a git repository configuration by ID", authz.PermGitReposDelete, h.DeleteRepository) + registerCustomizeSecuredInternal(api, "testGitRepository", "POST", "/customize/git-repositories/{id}/test", "Test a git repository", "Test connectivity and authentication to a git repository", authz.PermGitReposTest, h.TestRepository) + registerCustomizeSecuredInternal(api, "listGitRepositoryBranches", "GET", "/customize/git-repositories/{id}/branches", "List repository branches", "Get all branches from a git repository with default branch detection", authz.PermGitReposRead, h.ListBranches) + registerCustomizeSecuredInternal(api, "browseGitRepositoryFiles", "GET", "/customize/git-repositories/{id}/files", "Browse repository files", "Browse files and directories in a git repository", authz.PermGitReposRead, h.BrowseFiles) + registerTaggedSecuredInternal(api, "syncGitRepositories", "POST", "/git-repositories/sync", "Sync git repositories", "Sync git repositories from a manager to this agent instance", "Git Repositories", authz.PermGitReposSync, h.SyncRepositories) } // ============================================================================ @@ -283,10 +165,7 @@ func (h *GitRepositoryHandler) CreateRepository(ctx context.Context, input *Crea return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) repo, err := h.repoService.CreateRepository(ctx, input.Body, actor) if err != nil { @@ -294,16 +173,15 @@ func (h *GitRepositoryHandler) CreateRepository(ctx context.Context, input *Crea return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositoryCreationError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitRepository, gitops.GitRepository](repo) + body, mapErr := mapOneAPIResponseInternal[*models.GitRepository, gitops.GitRepository](repo, func(err error) string { + return (&common.GitRepositoryMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitRepositoryMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &CreateGitRepositoryOutput{ - Body: base.ApiResponse[gitops.GitRepository]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -319,16 +197,15 @@ func (h *GitRepositoryHandler) GetRepository(ctx context.Context, input *GetGitR return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositoryRetrievalError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitRepository, gitops.GitRepository](repo) + body, mapErr := mapOneAPIResponseInternal[*models.GitRepository, gitops.GitRepository](repo, func(err error) string { + return (&common.GitRepositoryMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitRepositoryMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &GetGitRepositoryOutput{ - Body: base.ApiResponse[gitops.GitRepository]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -338,10 +215,7 @@ func (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *Upda return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) repo, err := h.repoService.UpdateRepository(ctx, input.ID, input.Body, actor) if err != nil { @@ -349,16 +223,15 @@ func (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *Upda return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositoryUpdateError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitRepository, gitops.GitRepository](repo) + body, mapErr := mapOneAPIResponseInternal[*models.GitRepository, gitops.GitRepository](repo, func(err error) string { + return (&common.GitRepositoryMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitRepositoryMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &UpdateGitRepositoryOutput{ - Body: base.ApiResponse[gitops.GitRepository]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -368,10 +241,7 @@ func (h *GitRepositoryHandler) DeleteRepository(ctx context.Context, input *Dele return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) if err := h.repoService.DeleteRepository(ctx, input.ID, actor); err != nil { apiErr := models.ToAPIError(err) @@ -394,10 +264,7 @@ func (h *GitRepositoryHandler) TestRepository(ctx context.Context, input *TestGi return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) if err := h.repoService.TestConnection(ctx, input.ID, input.Branch, actor); err != nil { return nil, huma.Error400BadRequest((&common.GitRepositoryTestError{Err: err}).Error()) diff --git a/backend/api/handlers/gitops_syncs.go b/backend/api/handlers/gitops_syncs.go index ad04311db8..edfbb8af0d 100644 --- a/backend/api/handlers/gitops_syncs.go +++ b/backend/api/handlers/gitops_syncs.go @@ -4,12 +4,10 @@ import ( "context" "github.com/danielgtaylor/huma/v2" - humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/authz" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/gitops" ) @@ -126,131 +124,15 @@ type ImportGitOpsSyncsOutput struct { func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) { h := &GitOpsSyncHandler{syncService: syncService} - huma.Register(api, huma.Operation{ - OperationID: "listGitOpsSyncs", - Method: "GET", - Path: "/environments/{id}/gitops-syncs", - Summary: "List GitOps syncs", - Description: "Get a paginated list of GitOps syncs for an environment", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsList), - }, h.ListSyncs) - - huma.Register(api, huma.Operation{ - OperationID: "createGitOpsSync", - Method: "POST", - Path: "/environments/{id}/gitops-syncs", - Summary: "Create a GitOps sync", - Description: "Create a new GitOps sync configuration for an environment", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsCreate), - }, h.CreateSync) - - huma.Register(api, huma.Operation{ - OperationID: "importGitOpsSyncs", - Method: "POST", - Path: "/environments/{id}/gitops-syncs/import", - Summary: "Import GitOps syncs", - Description: "Import multiple GitOps sync configurations from JSON", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsCreate), - }, h.ImportSyncs) - - huma.Register(api, huma.Operation{ - OperationID: "getGitOpsSync", - Method: "GET", - Path: "/environments/{id}/gitops-syncs/{syncId}", - Summary: "Get a GitOps sync", - Description: "Get a GitOps sync by ID", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsRead), - }, h.GetSync) - - huma.Register(api, huma.Operation{ - OperationID: "updateGitOpsSync", - Method: "PUT", - Path: "/environments/{id}/gitops-syncs/{syncId}", - Summary: "Update a GitOps sync", - Description: "Update an existing GitOps sync configuration", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsUpdate), - }, h.UpdateSync) - - huma.Register(api, huma.Operation{ - OperationID: "deleteGitOpsSync", - Method: "DELETE", - Path: "/environments/{id}/gitops-syncs/{syncId}", - Summary: "Delete a GitOps sync", - Description: "Delete a GitOps sync configuration by ID", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsDelete), - }, h.DeleteSync) - - huma.Register(api, huma.Operation{ - OperationID: "performGitOpsSync", - Method: "POST", - Path: "/environments/{id}/gitops-syncs/{syncId}/sync", - Summary: "Perform a GitOps sync", - Description: "Manually trigger a sync operation", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsSync), - }, h.PerformSync) - - huma.Register(api, huma.Operation{ - OperationID: "getGitOpsSyncStatus", - Method: "GET", - Path: "/environments/{id}/gitops-syncs/{syncId}/status", - Summary: "Get GitOps sync status", - Description: "Get the current status of a GitOps sync", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsRead), - }, h.GetStatus) - - huma.Register(api, huma.Operation{ - OperationID: "browseGitOpsSyncFiles", - Method: "GET", - Path: "/environments/{id}/gitops-syncs/{syncId}/files", - Summary: "Browse GitOps sync files", - Description: "Browse files in the synced repository", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermGitOpsRead), - }, h.BrowseFiles) + registerGitOpsSecuredInternal(api, "listGitOpsSyncs", "GET", "/environments/{id}/gitops-syncs", "List GitOps syncs", "Get a paginated list of GitOps syncs for an environment", authz.PermGitOpsList, h.ListSyncs) + registerGitOpsSecuredInternal(api, "createGitOpsSync", "POST", "/environments/{id}/gitops-syncs", "Create a GitOps sync", "Create a new GitOps sync configuration for an environment", authz.PermGitOpsCreate, h.CreateSync) + registerGitOpsSecuredInternal(api, "importGitOpsSyncs", "POST", "/environments/{id}/gitops-syncs/import", "Import GitOps syncs", "Import multiple GitOps sync configurations from JSON", authz.PermGitOpsCreate, h.ImportSyncs) + registerGitOpsSecuredInternal(api, "getGitOpsSync", "GET", "/environments/{id}/gitops-syncs/{syncId}", "Get a GitOps sync", "Get a GitOps sync by ID", authz.PermGitOpsRead, h.GetSync) + registerGitOpsSecuredInternal(api, "updateGitOpsSync", "PUT", "/environments/{id}/gitops-syncs/{syncId}", "Update a GitOps sync", "Update an existing GitOps sync configuration", authz.PermGitOpsUpdate, h.UpdateSync) + registerGitOpsSecuredInternal(api, "deleteGitOpsSync", "DELETE", "/environments/{id}/gitops-syncs/{syncId}", "Delete a GitOps sync", "Delete a GitOps sync configuration by ID", authz.PermGitOpsDelete, h.DeleteSync) + registerGitOpsSecuredInternal(api, "performGitOpsSync", "POST", "/environments/{id}/gitops-syncs/{syncId}/sync", "Perform a GitOps sync", "Manually trigger a sync operation", authz.PermGitOpsSync, h.PerformSync) + registerGitOpsSecuredInternal(api, "getGitOpsSyncStatus", "GET", "/environments/{id}/gitops-syncs/{syncId}/status", "Get GitOps sync status", "Get the current status of a GitOps sync", authz.PermGitOpsRead, h.GetStatus) + registerGitOpsSecuredInternal(api, "browseGitOpsSyncFiles", "GET", "/environments/{id}/gitops-syncs/{syncId}/files", "Browse GitOps sync files", "Browse files in the synced repository", authz.PermGitOpsRead, h.BrowseFiles) } // ============================================================================ @@ -292,10 +174,7 @@ func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsS return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) sync, err := h.syncService.CreateSync(ctx, input.EnvironmentID, input.Body, actor) if err != nil { @@ -303,16 +182,15 @@ func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsS return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitOpsSyncCreationError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitOpsSync, gitops.GitOpsSync](sync) + body, mapErr := mapOneAPIResponseInternal[*models.GitOpsSync, gitops.GitOpsSync](sync, func(err error) string { + return (&common.GitOpsSyncMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitOpsSyncMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &CreateGitOpsSyncOutput{ - Body: base.ApiResponse[gitops.GitOpsSync]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -322,10 +200,7 @@ func (h *GitOpsSyncHandler) ImportSyncs(ctx context.Context, input *ImportGitOps return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) response, err := h.syncService.ImportSyncs(ctx, input.EnvironmentID, input.Body, actor) if err != nil { @@ -352,16 +227,15 @@ func (h *GitOpsSyncHandler) GetSync(ctx context.Context, input *GetGitOpsSyncInp return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitOpsSyncRetrievalError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitOpsSync, gitops.GitOpsSync](sync) + body, mapErr := mapOneAPIResponseInternal[*models.GitOpsSync, gitops.GitOpsSync](sync, func(err error) string { + return (&common.GitOpsSyncMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitOpsSyncMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &GetGitOpsSyncOutput{ - Body: base.ApiResponse[gitops.GitOpsSync]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -371,10 +245,7 @@ func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsS return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) sync, err := h.syncService.UpdateSync(ctx, input.EnvironmentID, input.SyncID, input.Body, actor) if err != nil { @@ -382,16 +253,15 @@ func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsS return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitOpsSyncUpdateError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitOpsSync, gitops.GitOpsSync](sync) + body, mapErr := mapOneAPIResponseInternal[*models.GitOpsSync, gitops.GitOpsSync](sync, func(err error) string { + return (&common.GitOpsSyncMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitOpsSyncMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &UpdateGitOpsSyncOutput{ - Body: base.ApiResponse[gitops.GitOpsSync]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -401,10 +271,7 @@ func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsS return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) if err := h.syncService.DeleteSync(ctx, input.EnvironmentID, input.SyncID, actor); err != nil { apiErr := models.ToAPIError(err) @@ -427,10 +294,7 @@ func (h *GitOpsSyncHandler) PerformSync(ctx context.Context, input *PerformSyncI return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) result, err := h.syncService.PerformSync(ctx, input.EnvironmentID, input.SyncID, actor) if err != nil { diff --git a/backend/api/handlers/helpers.go b/backend/api/handlers/helpers.go index 20ad2937e3..ffb700bf1d 100644 --- a/backend/api/handlers/helpers.go +++ b/backend/api/handlers/helpers.go @@ -6,9 +6,13 @@ import ( "errors" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/remenv" + "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" + "github.com/getarcaneapp/arcane/types/base" ) // ActivityAppContext carries the app lifecycle context through handler registration. @@ -54,6 +58,95 @@ func buildPaginationParamsInternal(start, limit int, sortCol, sortDir, search st } } +func registerSecuredInternal[I, O any]( + api huma.API, + op huma.Operation, + permission string, + handler func(context.Context, *I) (*O, error), +) { + op.Security = defaultOperationSecurityInternal() + op.Middlewares = humamw.RequirePermission(api, permission) + huma.Register(api, op, handler) +} + +func registerCustomizeSecuredInternal[I, O any]( + api huma.API, + operationID string, + method string, + path string, + summary string, + description string, + permission string, + handler func(context.Context, *I) (*O, error), +) { + registerTaggedSecuredInternal(api, operationID, method, path, summary, description, "Customize", permission, handler) +} + +func registerGitOpsSecuredInternal[I, O any]( + api huma.API, + operationID string, + method string, + path string, + summary string, + description string, + permission string, + handler func(context.Context, *I) (*O, error), +) { + registerTaggedSecuredInternal(api, operationID, method, path, summary, description, "GitOps Syncs", permission, handler) +} + +func registerTaggedSecuredInternal[I, O any]( + api huma.API, + operationID string, + method string, + path string, + summary string, + description string, + tag string, + permission string, + handler func(context.Context, *I) (*O, error), +) { + registerSecuredInternal(api, operationInternal(operationID, method, path, summary, description, tag), permission, handler) +} + +func operationInternal(operationID, method, path, summary, description string, tags ...string) huma.Operation { + return huma.Operation{ + OperationID: operationID, + Method: method, + Path: path, + Summary: summary, + Description: description, + Tags: tags, + } +} + +func currentActorInternal(ctx context.Context) models.User { + actor := models.User{} + if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { + actor = *currentUser + } + return actor +} + +func mapOneAPIResponseInternal[S any, D any](source S, mappingMessage func(error) string) (base.ApiResponse[D], error) { + out, err := mapper.MapOne[S, D](source) + if err != nil { + return base.ApiResponse[D]{}, huma.Error500InternalServerError(mappingMessage(err)) + } + + return base.ApiResponse[D]{ + Success: true, + Data: out, + }, nil +} + +func defaultOperationSecurityInternal() []map[string][]string { + return []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + } +} + func proxyRemoteJSONInternal[T any]( ctx context.Context, environmentService *services.EnvironmentService, @@ -87,18 +180,15 @@ func marshalRemoteRequestBodyInternal(requestBody any) ([]byte, error) { } func translateRemoteProxyErrorInternal(err error) error { - var transportErr *remenv.TransportError - if errors.As(err, &transportErr) { + if transportErr, ok := errors.AsType[*remenv.TransportError](err); ok { return huma.Error502BadGateway("failed to proxy request to environment: " + transportErr.Error()) } - var statusErr *remenv.StatusError - if errors.As(err, &statusErr) { + if statusErr, ok := errors.AsType[*remenv.StatusError](err); ok { return huma.NewError(statusErr.StatusCode, "environment returned error: "+string(statusErr.Body), nil) } - var decodeErr *remenv.DecodeError - if errors.As(err, &decodeErr) { + if decodeErr, ok := errors.AsType[*remenv.DecodeError](err); ok { return huma.Error500InternalServerError("failed to decode environment response: " + decodeErr.Error()) } diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index b6242dc1c9..c214f1bfde 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -17,6 +17,7 @@ import ( activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/image" "github.com/getarcaneapp/arcane/types/system" @@ -446,10 +447,7 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() @@ -501,10 +499,7 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) ( return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index 68d4243380..431399c482 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -19,6 +19,7 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/pagination" projects "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/project" @@ -595,10 +596,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() @@ -664,8 +662,7 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu if err := h.projectService.DownProject(downCtx, input.ProjectID, *user); err != nil { activitylib.FlushWriter(activityWriter) activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project stopped", err) - var archivedErr *common.ProjectArchivedError - if errors.As(err, &archivedErr) { + if _, ok := errors.AsType[*common.ProjectArchivedError](err); ok { return nil, huma.Error400BadRequest((&common.ProjectDownError{Err: err}).Error()) } return nil, huma.Error500InternalServerError((&common.ProjectDownError{Err: err}).Error()) @@ -851,55 +848,13 @@ func (h *ProjectHandler) GetProjectFile(ctx context.Context, input *GetProjectFi // RedeployProject redeploys a Docker Compose project. func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployProjectInput) (*RedeployProjectOutput, error) { - if h.projectService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - if input.ProjectID == "" { - return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error()) - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) - } - - runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( - runtimeCtx, - h.activityService, - input.EnvironmentID, - models.ActivityTypeProjectRedeploy, - "project", - input.ProjectID, - input.ProjectID, - user, - "Starting redeploy", - "Project redeploy started", - models.JSON{"projectID": input.ProjectID}, - ) - activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Redeploying project") - redeployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) - if err := h.projectService.RedeployProject(redeployCtx, input.ProjectID, *user); err != nil { - activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project redeploy failed", err) - var archivedErr *common.ProjectArchivedError - if errors.As(err, &archivedErr) { - return nil, huma.Error400BadRequest(err.Error()) - } - return nil, huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error()) + response, err := h.runProjectActivityActionResponseInternal(ctx, input.EnvironmentID, input.ProjectID, h.redeployProjectActivityConfigInternal()) + if err != nil { + return nil, err } - activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project redeploy completed", nil) return &RedeployProjectOutput{ - Body: base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{ - Message: "Project redeployed successfully", - ActivityID: utils.StringPtrFromTrimmed(activityID), - }, - }, + Body: response, }, nil } @@ -1054,43 +1009,119 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update // RestartProject restarts all containers in a project. func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProjectInput) (*RestartProjectOutput, error) { + response, err := h.runProjectActivityActionResponseInternal(ctx, input.EnvironmentID, input.ProjectID, h.restartProjectActivityConfigInternal()) + if err != nil { + return nil, err + } + + return &RestartProjectOutput{ + Body: response, + }, nil +} + +type projectActivityActionConfigInternal struct { + ActivityType models.ActivityType + Step string + StartMessage string + WriterStep string + FailureMessage string + SuccessComplete string + SuccessMessage string + Action func(context.Context, string, models.User) error + Error func(error) error +} + +func (h *ProjectHandler) redeployProjectActivityConfigInternal() projectActivityActionConfigInternal { + return projectActivityActionConfigInternal{ + ActivityType: models.ActivityTypeProjectRedeploy, + Step: "Starting redeploy", + StartMessage: "Project redeploy started", + WriterStep: "Redeploying project", + FailureMessage: "Project redeploy failed", + SuccessComplete: "Project redeploy completed", + SuccessMessage: "Project redeployed successfully", + Action: func(runtimeCtx context.Context, projectID string, user models.User) error { + return h.projectService.RedeployProject(runtimeCtx, projectID, user) + }, + Error: projectArchivedActionErrorInternal(func(err error) error { + return huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error()) + }), + } +} + +func (h *ProjectHandler) restartProjectActivityConfigInternal() projectActivityActionConfigInternal { + return projectActivityActionConfigInternal{ + ActivityType: models.ActivityTypeProjectRestart, + Step: "Restarting project", + StartMessage: "Project restart requested", + WriterStep: "Restarting project", + FailureMessage: "Project restarted", + SuccessComplete: "Project restarted", + SuccessMessage: "Project restarted successfully", + Action: func(runtimeCtx context.Context, projectID string, user models.User) error { + return h.projectService.RestartProject(runtimeCtx, projectID, user) + }, + Error: projectArchivedActionErrorInternal(func(err error) error { + return huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error()) + }), + } +} + +func projectArchivedActionErrorInternal(fallback func(error) error) func(error) error { + return func(err error) error { + if _, ok := errors.AsType[*common.ProjectArchivedError](err); ok { + return huma.Error400BadRequest(err.Error()) + } + return fallback(err) + } +} + +func (h *ProjectHandler) runProjectActivityActionResponseInternal( + ctx context.Context, + environmentID string, + projectID string, + cfg projectActivityActionConfigInternal, +) (base.ApiResponse[base.MessageResponse], error) { + message, err := h.runProjectActivityActionInternal(ctx, environmentID, projectID, cfg) + if err != nil { + return base.ApiResponse[base.MessageResponse]{}, err + } + + return base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: message, + }, nil +} + +func (h *ProjectHandler) runProjectActivityActionInternal(ctx context.Context, environmentID, projectID string, cfg projectActivityActionConfigInternal) (base.MessageResponse, error) { if h.projectService == nil { - return nil, huma.Error500InternalServerError("service not available") + return base.MessageResponse{}, huma.Error500InternalServerError("service not available") } - if input.ProjectID == "" { - return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error()) + if projectID == "" { + return base.MessageResponse{}, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error()) } user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { - return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + return base.MessageResponse{}, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID}) - activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Restarting project") - restartCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) - if err := h.projectService.RestartProject(restartCtx, input.ProjectID, *user); err != nil { + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, environmentID, cfg.ActivityType, "project", projectID, projectID, user, cfg.Step, cfg.StartMessage, models.JSON{"projectID": projectID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, cfg.WriterStep) + actionCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) + if err := cfg.Action(actionCtx, projectID, *user); err != nil { activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project restarted", err) - var archivedErr *common.ProjectArchivedError - if errors.As(err, &archivedErr) { - return nil, huma.Error400BadRequest(err.Error()) - } - return nil, huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error()) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.FailureMessage, err) + return base.MessageResponse{}, cfg.Error(err) } activitylib.FlushWriter(activityWriter) - activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project restarted", nil) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.SuccessComplete, nil) - return &RestartProjectOutput{ - Body: base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{ - Message: "Project restarted successfully", - ActivityID: utils.StringPtrFromTrimmed(activityID), - }, - }, + return base.MessageResponse{ + Message: cfg.SuccessMessage, + ActivityID: utils.StringPtrFromTrimmed(activityID), }, nil } @@ -1109,8 +1140,7 @@ func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProje } if err := h.projectService.ArchiveProject(ctx, input.ProjectID, *user); err != nil { - var mustStopErr *common.ProjectMustBeStoppedError - if errors.As(err, &mustStopErr) { + if _, ok := errors.AsType[*common.ProjectMustBeStoppedError](err); ok { return nil, huma.Error400BadRequest(err.Error()) } return nil, huma.Error500InternalServerError((&common.ProjectArchiveError{Err: err}).Error()) @@ -1167,12 +1197,9 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) - runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) //nolint:contextcheck // activity context intentionally outlives the HTTP stream (uses app lifecycle context) rawWriter := humaCtx.BodyWriter() activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, @@ -1239,12 +1266,9 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) - runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) //nolint:contextcheck // activity context intentionally outlives the HTTP stream (uses app lifecycle context) rawWriter := humaCtx.BodyWriter() activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, diff --git a/backend/api/handlers/settings_test.go b/backend/api/handlers/settings_test.go index 89fa455f23..3b1cc145d1 100644 --- a/backend/api/handlers/settings_test.go +++ b/backend/api/handlers/settings_test.go @@ -87,8 +87,7 @@ func TestSettingsHandler_UpdateLocalEnvironment_RejectsUnreadableProjectsDirecto handler := &SettingsHandler{settingsService: settingsSvc, cfg: &config.Config{}} - dirPtr := unreadable - _, err = handler.updateSettingsForLocalEnvironment(ctx, apitypes.Update{ProjectsDirectory: &dirPtr}) + _, err = handler.updateSettingsForLocalEnvironment(ctx, apitypes.Update{ProjectsDirectory: new(unreadable)}) require.Error(t, err) var statusErr huma.StatusError diff --git a/backend/api/handlers/volumes.go b/backend/api/handlers/volumes.go index 2f318272e2..e205454aa0 100644 --- a/backend/api/handlers/volumes.go +++ b/backend/api/handlers/volumes.go @@ -1045,66 +1045,66 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) } func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) { - if h.volumeService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - user, _ := humamw.GetCurrentUserFromContext(ctx) - runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) - activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ - EnvironmentID: input.EnvironmentID, - Type: models.ActivityTypeResourceAction, - ResourceType: "volume", - ResourceID: input.VolumeName, - ResourceName: input.VolumeName, - User: user, + return h.runVolumePathActivityInternal(ctx, input.EnvironmentID, input.VolumeName, input.Path, volumePathActivityConfigInternal{ Step: "Creating directory", Message: "Creating directory in volume", SuccessMessage: "Directory created successfully", - Metadata: models.JSON{ - "action": "create_volume_directory", - "path": input.Path, + MetadataAction: "create_volume_directory", + Action: func(runtimeCtx context.Context, volumeName, path string, user *models.User) error { + return h.volumeService.CreateDirectory(runtimeCtx, volumeName, path, user) }, - }, func(runtimeCtx context.Context) error { - return h.volumeService.CreateDirectory(runtimeCtx, input.VolumeName, input.Path, user) }) - if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) - } - return &base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{Message: "Directory created successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, - }, nil } func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) (*base.ApiResponse[base.MessageResponse], error) { + return h.runVolumePathActivityInternal(ctx, input.EnvironmentID, input.VolumeName, input.Path, volumePathActivityConfigInternal{ + Step: "Deleting file", + Message: "Deleting file or directory from volume", + SuccessMessage: "Deleted successfully", + MetadataAction: "delete_volume_file", + Action: func(runtimeCtx context.Context, volumeName, path string, user *models.User) error { + return h.volumeService.DeleteFile(runtimeCtx, volumeName, path, user) + }, + }) +} + +type volumePathActivityConfigInternal struct { + Step string + Message string + SuccessMessage string + MetadataAction string + Action func(context.Context, string, string, *models.User) error +} + +func (h *VolumeHandler) runVolumePathActivityInternal(ctx context.Context, environmentID, volumeName, volumePath string, cfg volumePathActivityConfigInternal) (*base.ApiResponse[base.MessageResponse], error) { if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ - EnvironmentID: input.EnvironmentID, + EnvironmentID: environmentID, Type: models.ActivityTypeResourceAction, ResourceType: "volume", - ResourceID: input.VolumeName, - ResourceName: input.VolumeName, + ResourceID: volumeName, + ResourceName: volumeName, User: user, - Step: "Deleting file", - Message: "Deleting file or directory from volume", - SuccessMessage: "Deleted successfully", + Step: cfg.Step, + Message: cfg.Message, + SuccessMessage: cfg.SuccessMessage, Metadata: models.JSON{ - "action": "delete_volume_file", - "path": input.Path, + "action": cfg.MetadataAction, + "path": volumePath, }, }, func(runtimeCtx context.Context) error { - return h.volumeService.DeleteFile(runtimeCtx, input.VolumeName, input.Path, user) + return cfg.Action(runtimeCtx, volumeName, volumePath, user) }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Deleted successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, + Data: base.MessageResponse{Message: cfg.SuccessMessage, ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, nil } diff --git a/backend/go.mod b/backend/go.mod index ef46e361e1..e2673d2353 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,7 +23,7 @@ require ( github.com/docker/go-units v0.5.0 github.com/fsnotify/fsnotify v1.10.1 github.com/getarcaneapp/arcane/cli v1.19.1 - github.com/getarcaneapp/arcane/types v1.19.4 + github.com/getarcaneapp/arcane/types v1.19.5 github.com/glebarez/sqlite v1.11.0 github.com/go-git/go-git/v5 v5.19.1 github.com/goccy/go-yaml v1.19.2 @@ -204,17 +204,17 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index f2dd721346..76af66d44f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -557,10 +557,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0 h1:cuXaPAfIoJKsYjBjPSb2nKZEmgM43zVr25l37IxhKME= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0/go.mod h1:BuzhPofpCzlDi/Q/Xjg54M4/3oWqqyDe2Zeq7A2I0QE= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= @@ -571,14 +569,11 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+J go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/backend/internal/bootstrap/edge_bootstrap.go b/backend/internal/bootstrap/edge_bootstrap.go index 8d85672b82..018ecea140 100644 --- a/backend/internal/bootstrap/edge_bootstrap.go +++ b/backend/internal/bootstrap/edge_bootstrap.go @@ -112,7 +112,6 @@ func registerEdgeTunnelRoutes( if env, err := appServices.Environment.GetEnvironmentByID(ctx, envID); err == nil && env != nil { envName = env.Name } - resourceType := "environment" envIDCopy := envID envNameCopy := envName _, _ = appServices.Event.CreateEvent(ctx, services.CreateEventRequest{ @@ -120,7 +119,7 @@ func registerEdgeTunnelRoutes( Severity: edgeMTLSEnrollmentSeverityInternal(reenrolled), Title: "Edge mTLS enrollment", Description: fmt.Sprintf("Edge agent completed mTLS enrollment from %s", remoteAddr), - ResourceType: &resourceType, + ResourceType: new("environment"), ResourceID: &envIDCopy, ResourceName: &envNameCopy, EnvironmentID: &envIDCopy, @@ -157,13 +156,12 @@ func createEdgeMTLSIssueEventsInternal(ctx context.Context, eventService *servic }) } if certIssued { - resourceType := "environment" _, _ = eventService.CreateEvent(ctx, services.CreateEventRequest{ Type: models.EventTypeEnvironmentMTLSCertIssued, Severity: edgeMTLSCertIssuedSeverityInternal(reenrolled), Title: "Edge mTLS certificate issued", Description: fmt.Sprintf("Arcane issued an edge mTLS client certificate for environment '%s'", envName), - ResourceType: &resourceType, + ResourceType: new("environment"), ResourceID: &envID, ResourceName: &envName, EnvironmentID: &envID, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 11875a702f..806e7c7585 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -170,7 +170,7 @@ func loadFromEnv(cfg *Config) { defaultValue := fieldType.Tag.Get("default") // Get the environment value directly first - envValue := trimQuotes(os.Getenv(envTag)) + envValue := pkgutils.TrimQuotes(os.Getenv(envTag)) if envValue == "" { envValue = defaultValue } @@ -371,15 +371,6 @@ func setFieldValueInternal(field reflect.Value, fieldType reflect.StructField, v } } -func trimQuotes(s string) string { - if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { - return s[1 : len(s)-1] - } - } - return s -} - func (a AppEnvironment) IsProdEnvironment() bool { return a == AppEnvironmentProduction } diff --git a/backend/internal/middleware/environment_middleware.go b/backend/internal/middleware/environment_middleware.go index f79e76439a..7d668e0ebd 100644 --- a/backend/internal/middleware/environment_middleware.go +++ b/backend/internal/middleware/environment_middleware.go @@ -342,8 +342,7 @@ func (m *EnvironmentMiddleware) proxyHTTP(c echo.Context, target string, accessT req, err := m.createProxyRequest(c, target, accessToken) if err != nil { errMessage := (&common.EnvironmentProxyRequestCreationError{Err: err}).Error() - var invalidTargetErr *common.EnvironmentInvalidProxyTargetError - if errors.As(err, &invalidTargetErr) { + if invalidTargetErr, ok := errors.AsType[*common.EnvironmentInvalidProxyTargetError](err); ok { errMessage = invalidTargetErr.Error() } return c.JSON(http.StatusInternalServerError, map[string]any{ diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go index f320d49f0d..bcc40abec7 100644 --- a/backend/internal/services/activity_service.go +++ b/backend/internal/services/activity_service.go @@ -420,8 +420,7 @@ func (s *ActivityService) CancelActivity(ctx context.Context, environmentID, act // terminal status, which reaches clients via the activity stream. Return // the pre-cancel snapshot rather than reloading here: the worker has not // finished unwinding yet, so a reload would still report "running". - dto := activityToDTOInternal(&model) - return &dto, nil + return new(activityToDTOInternal(&model)), nil } // Untracked work (e.g. after a process restart, or a queued activity with no @@ -762,8 +761,7 @@ func copyPtrInternal[T any](value *T) *T { if value == nil { return nil } - out := *value - return &out + return new(*value) } func clampProgressPtrInternal(value *int) *int { diff --git a/backend/internal/services/activity_service_test.go b/backend/internal/services/activity_service_test.go index 44922daf84..8942b0b9c9 100644 --- a/backend/internal/services/activity_service_test.go +++ b/backend/internal/services/activity_service_test.go @@ -31,22 +31,18 @@ func TestActivityServiceLifecycleInternal(t *testing.T) { db := setupActivityServiceTestDBInternal(t) service := NewActivityService(db) - resourceType := "image" - resourceID := "img-123" - resourceName := "nginx:latest" progress := 5 - displayName := "Arcane Admin" startedBy := &models.User{ BaseModel: models.BaseModel{ID: "user-1"}, Username: "arcane", - DisplayName: &displayName, + DisplayName: new("Arcane Admin"), } created, err := service.StartActivity(ctx, StartActivityRequest{ EnvironmentID: "0", Type: models.ActivityTypeImagePull, - ResourceType: &resourceType, - ResourceID: &resourceID, - ResourceName: &resourceName, + ResourceType: new("image"), + ResourceID: new("img-123"), + ResourceName: new("nginx:latest"), StartedBy: startedBy, Progress: &progress, Step: "queued", diff --git a/backend/internal/services/api_key_service.go b/backend/internal/services/api_key_service.go index 57d6028574..c4ad15b800 100644 --- a/backend/internal/services/api_key_service.go +++ b/backend/internal/services/api_key_service.go @@ -449,8 +449,7 @@ func (s *ApiKeyService) GetApiKey(ctx context.Context, id string) (*apikey.ApiKe } return nil, fmt.Errorf("failed to get API key: %w", err) } - dto := s.toAPIKeyDTOWithPermissionsInternal(ctx, &ak) - return &dto, nil + return new(s.toAPIKeyDTOWithPermissionsInternal(ctx, &ak)), nil } func (s *ApiKeyService) ListApiKeys(ctx context.Context, params pagination.QueryParams) ([]apikey.ApiKey, pagination.Response, error) { diff --git a/backend/internal/services/api_key_service_test.go b/backend/internal/services/api_key_service_test.go index 638f68de50..22204def6e 100644 --- a/backend/internal/services/api_key_service_test.go +++ b/backend/internal/services/api_key_service_test.go @@ -219,9 +219,8 @@ func TestUpdateApiKeyRollsBackMetadataWhenPermissionUpdateFails(t *testing.T) { created, err := service.CreateApiKey(ctx, admin.ID, apikey.CreateApiKey{Name: "original"}) require.NoError(t, err) - updatedName := "renamed" updated, err := service.UpdateApiKey(ctx, admin.ID, created.ApiKey.ID, apikey.UpdateApiKey{ - Name: &updatedName, + Name: new("renamed"), Permissions: []apikey.PermissionGrant{ {Permission: authz.PermContainersList}, {Permission: authz.PermContainersList}, diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 258b412626..722e8e1377 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -722,8 +722,7 @@ func (s *AuthService) VerifyToken(ctx context.Context, accessToken string) (*mod tokenHash := hashTokenInternal(accessToken) if cached, ok := s.tokenCache.Get(tokenHash); ok { - u := cached.User - return &u, cached.SessionID, nil + return new(cached.User), cached.SessionID, nil } // Verify user exists in DB diff --git a/backend/internal/services/build_service.go b/backend/internal/services/build_service.go index 5c86e4213b..a713436a4e 100644 --- a/backend/internal/services/build_service.go +++ b/backend/internal/services/build_service.go @@ -155,12 +155,10 @@ func (s *BuildService) BuildImage(ctx context.Context, environmentID string, req var errMsg *string if err != nil { status = models.ImageBuildStatusFailed - errorText := err.Error() - errMsg = &errorText + errMsg = new(err.Error()) } - durationMs := completedAt.Sub(startedAt).Milliseconds() - if updateErr := s.completeBuildRecord(ctx, buildRecordID, status, outputPtr, logCapture.Truncated(), errMsg, digest, provider, completedAt, &durationMs); updateErr != nil { + if updateErr := s.completeBuildRecord(ctx, buildRecordID, status, outputPtr, logCapture.Truncated(), errMsg, digest, provider, completedAt, new(completedAt.Sub(startedAt).Milliseconds())); updateErr != nil { slog.WarnContext(ctx, "failed to update build history record", "error", updateErr) } } diff --git a/backend/internal/services/container_registry_service.go b/backend/internal/services/container_registry_service.go index 00aa105abb..2cbcf1fc4f 100644 --- a/backend/internal/services/container_registry_service.go +++ b/backend/internal/services/container_registry_service.go @@ -23,7 +23,6 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cache" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/containerregistry" dockerregistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" @@ -99,25 +98,14 @@ func (s *ContainerRegistryService) GetRegistriesPaginated(ctx context.Context, p var registries []models.ContainerRegistry q := s.db.WithContext(ctx).Model(&models.ContainerRegistry{}) - if term := strings.TrimSpace(params.Search); term != "" { - searchPattern := "%" + term + "%" - q = q.Where( - "url LIKE ? OR username LIKE ? OR COALESCE(description, '') LIKE ?", - searchPattern, searchPattern, searchPattern, - ) - } + q = pagination.ApplyLikeSearch(q, params.Search, "url LIKE ? OR username LIKE ? OR COALESCE(description, '') LIKE ?") q = pagination.ApplyBooleanFilter(q, "enabled", params.Filters["enabled"]) q = pagination.ApplyBooleanFilter(q, "insecure", params.Filters["insecure"]) - paginationResp, err := pagination.PaginateAndSortDB(params, q, ®istries) + out, paginationResp, err := pagination.PaginateSortAndMapDB[models.ContainerRegistry, containerregistry.ContainerRegistry](params, q, ®istries) if err != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to paginate container registries: %w", err) - } - - out, mapErr := mapper.MapSlice[models.ContainerRegistry, containerregistry.ContainerRegistry](registries) - if mapErr != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to map registries: %w", mapErr) + return nil, pagination.Response{}, fmt.Errorf("failed to list container registries: %w", err) } return out, paginationResp, nil diff --git a/backend/internal/services/container_service.go b/backend/internal/services/container_service.go index 0b979c4c67..4c7f582df3 100644 --- a/backend/internal/services/container_service.go +++ b/backend/internal/services/container_service.go @@ -1,7 +1,6 @@ package services import ( - "bufio" "context" "encoding/json" "errors" @@ -27,7 +26,6 @@ import ( containertypes "github.com/getarcaneapp/arcane/types/container" "github.com/getarcaneapp/arcane/types/containerregistry" imagetypes "github.com/getarcaneapp/arcane/types/image" - "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/events" "github.com/moby/moby/api/types/network" @@ -365,8 +363,8 @@ func (s *ContainerService) tryRedeployViaComposeProjectInternal(ctx context.Cont return "", false, nil } labels := containerInfo.Config.Labels - projectName := strings.TrimSpace(labels["com.docker.compose.project"]) - serviceName := strings.TrimSpace(labels["com.docker.compose.service"]) + projectName := dockerutils.ComposeProjectLabel(labels) + serviceName := dockerutils.ComposeServiceLabel(labels) if projectName == "" || serviceName == "" { return "", false, nil } @@ -870,97 +868,7 @@ func (s *ContainerService) StreamLogs(ctx context.Context, containerID string, l defer func() { _ = logs.Close() }() isTTY := containerInspect.Container.Config != nil && containerInspect.Container.Config.Tty - return s.streamContainerLogsInternal(ctx, logs, logsChan, follow, isTTY) -} - -func (s *ContainerService) streamContainerLogsInternal(ctx context.Context, logs io.ReadCloser, logsChan chan<- string, follow bool, isTTY bool) error { - if isTTY { - return s.streamRawLogsInternal(ctx, logs, logsChan) - } - if follow { - return streamMultiplexedLogs(ctx, logs, logsChan) - } - return s.readAllLogs(ctx, logs, logsChan) -} - -func (s *ContainerService) streamRawLogsInternal(ctx context.Context, logs io.Reader, logsChan chan<- string) error { - return s.readLogsFromReader(ctx, logs, logsChan, "") -} - -// readLogsFromReader reads logs line by line from a reader -func (s *ContainerService) readLogsFromReader(ctx context.Context, reader io.Reader, logsChan chan<- string, prefix string) error { - bufferedReader := bufio.NewReader(reader) - - for { - if err := ctx.Err(); err != nil { - return err - } - - line, err := bufferedReader.ReadString('\n') - if len(line) > 0 { - trimmed := strings.TrimRight(line, "\r\n") - if trimmed != "" { - if prefix != "" { - trimmed = prefix + trimmed - } - - select { - case logsChan <- trimmed: - case <-ctx.Done(): - return ctx.Err() - } - } - } - - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func (s *ContainerService) readAllLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string) error { - stdoutBuf := &strings.Builder{} - stderrBuf := &strings.Builder{} - stdCopyDone := make(chan struct{}) - defer close(stdCopyDone) - - go func() { - select { - case <-ctx.Done(): - _ = logs.Close() - case <-stdCopyDone: - } - }() - - _, err := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) - if err != nil && !errors.Is(err, io.EOF) { - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - return fmt.Errorf("failed to demultiplex logs: %w", err) - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - - // Send stdout lines - if stdoutBuf.Len() > 0 { - if err := s.readLogsFromReader(ctx, strings.NewReader(stdoutBuf.String()), logsChan, ""); err != nil { - return err - } - } - - // Send stderr lines with prefix - if stderrBuf.Len() > 0 { - if err := s.readLogsFromReader(ctx, strings.NewReader(stderrBuf.String()), logsChan, "[STDERR] "); err != nil { - return err - } - } - - return nil + return dockerutils.StreamContainerLogs(ctx, logs, logsChan, follow, isTTY) } func (s *ContainerService) ListContainersPaginated( @@ -1134,7 +1042,7 @@ func getContainerProjectNameInternal(container containertypes.Summary) string { return containerNoProjectGroup } - projectName := strings.TrimSpace(container.Labels["com.docker.compose.project"]) + projectName := dockerutils.ComposeProjectLabel(container.Labels) if projectName == "" { return containerNoProjectGroup } @@ -1389,7 +1297,7 @@ func (s *ContainerService) buildContainerFilterAccessors() []pagination.FilterAc { Key: "standalone", Fn: func(c containertypes.Summary, filterValue string) bool { - isStandalone := strings.TrimSpace(c.Labels["com.docker.compose.project"]) == "" + isStandalone := dockerutils.ComposeProjectLabel(c.Labels) == "" switch filterValue { case "true", "1": return isStandalone diff --git a/backend/internal/services/container_service_test.go b/backend/internal/services/container_service_test.go index f3c3e16ef7..13f39f17c4 100644 --- a/backend/internal/services/container_service_test.go +++ b/backend/internal/services/container_service_test.go @@ -1,14 +1,8 @@ package services import ( - "bytes" - "context" - "encoding/binary" - "io" "net/netip" - "strings" "testing" - "time" "github.com/getarcaneapp/arcane/backend/pkg/pagination" containertypes "github.com/getarcaneapp/arcane/types/container" @@ -121,86 +115,6 @@ func TestBuildCleanNetworkingConfigInternalPreservesEndpointSettings(t *testing. require.Nil(t, out.EndpointsConfig["bridge"].IPAMConfig) } -func TestStreamContainerLogs_NonTTYFollowDemultiplexesStdoutAndStderr(t *testing.T) { - var stream bytes.Buffer - writeDockerLogFrameInternal(t, &stream, 1, "stdout line\n") - writeDockerLogFrameInternal(t, &stream, 2, "stderr line\n") - - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, true, false) - require.NoError(t, err) - - require.ElementsMatch(t, []string{"stdout line", "[STDERR] stderr line"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_TTYFollowStreamsRawOutput(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(strings.NewReader("first line\nsecond line")), logsChan, true, true) - require.NoError(t, err) - - require.Equal(t, []string{"first line", "second line"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_NonTTYSnapshotDemultiplexesStdoutAndStderr(t *testing.T) { - var stream bytes.Buffer - writeDockerLogFrameInternal(t, &stream, 1, "stdout snapshot\n") - writeDockerLogFrameInternal(t, &stream, 2, "stderr snapshot\n") - - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, false, false) - require.NoError(t, err) - - require.Equal(t, []string{"stdout snapshot", "[STDERR] stderr snapshot"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_TTYSnapshotStreamsRawOutput(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(strings.NewReader("snapshot line\ntrailing line")), logsChan, false, true) - require.NoError(t, err) - - require.Equal(t, []string{"snapshot line", "trailing line"}, drainLogLinesInternal(logsChan)) -} - -func TestReadLogsFromReader_HandlesLongLinesAndPartialEOF(t *testing.T) { - longLine := strings.Repeat("a", 70*1024) - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.readLogsFromReader(t.Context(), strings.NewReader(longLine+"\npartial tail"), logsChan, "") - require.NoError(t, err) - - require.Equal(t, []string{longLine, "partial tail"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_TTYPythonLikeFollowDoesNotReturnEmptyLogs(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal( - t.Context(), - io.NopCloser(strings.NewReader("2026-03-22 10:15:00 - INFO - Starting miner\n2026-03-22 10:15:01 - INFO - Connected")), - logsChan, - true, - true, - ) - require.NoError(t, err) - - lines := drainLogLinesInternal(logsChan) - require.NotEmpty(t, lines) - require.Equal(t, []string{ - "2026-03-22 10:15:00 - INFO - Starting miner", - "2026-03-22 10:15:01 - INFO - Connected", - }, lines) -} - func TestCompareContainerPortsForSortDesc_KeepsContainersWithoutPortsLast(t *testing.T) { withPublished := containertypes.Summary{ ID: "published", @@ -222,69 +136,6 @@ func TestCompareContainerPortsForSortDesc_KeepsContainersWithoutPortsLast(t *tes require.Equal(t, 1, compareContainerPortsForSortDescInternal(withoutPorts, withPublished)) } -func TestStreamMultiplexedLogs_ContextCancelDoesNotDeadlock(t *testing.T) { - var stream bytes.Buffer - writeDockerLogFrameInternal(t, &stream, 1, "line 1\n") - writeDockerLogFrameInternal(t, &stream, 1, "line 2\n") - writeDockerLogFrameInternal(t, &stream, 1, "line 3\n") - - logsChan := make(chan string, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - done := make(chan error, 1) - go func() { - done <- streamMultiplexedLogs(ctx, io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan) - }() - - require.Eventually(t, func() bool { - return len(logsChan) == 1 - }, time.Second, 10*time.Millisecond) - - cancel() - - select { - case err := <-done: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("streamMultiplexedLogs did not exit after cancellation") - } -} - -func TestReadAllLogs_ContextCancelClosesReader(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - reader := &blockingReadCloser{readStarted: make(chan struct{}), closeCalled: make(chan struct{})} - done := make(chan error, 1) - go func() { - done <- service.readAllLogs(ctx, reader, logsChan) - }() - - select { - case <-reader.readStarted: - case <-time.After(time.Second): - t.Fatal("readAllLogs did not start reading") - } - - cancel() - - select { - case err := <-done: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("readAllLogs did not exit after cancellation") - } - - select { - case <-reader.closeCalled: - case <-time.After(time.Second): - t.Fatal("readAllLogs did not close the reader on cancellation") - } -} - func newGroupedContainerSummary(name string, project string) containertypes.Summary { labels := map[string]string{} if project != "" { @@ -298,52 +149,3 @@ func newGroupedContainerSummary(name string, project string) containertypes.Summ State: "running", } } - -func drainLogLinesInternal(logsChan chan string) []string { - close(logsChan) - - lines := make([]string, 0, len(logsChan)) - for line := range logsChan { - lines = append(lines, line) - } - - return lines -} - -func writeDockerLogFrameInternal(t *testing.T, buffer *bytes.Buffer, streamType byte, payload string) { - t.Helper() - - header := make([]byte, 8) - header[0] = streamType - binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) - - _, err := buffer.Write(header) - require.NoError(t, err) - _, err = buffer.WriteString(payload) - require.NoError(t, err) -} - -type blockingReadCloser struct { - readStarted chan struct{} - closeCalled chan struct{} -} - -func (r *blockingReadCloser) Read(_ []byte) (int, error) { - select { - case <-r.readStarted: - default: - close(r.readStarted) - } - - <-r.closeCalled - return 0, io.EOF -} - -func (r *blockingReadCloser) Close() error { - select { - case <-r.closeCalled: - default: - close(r.closeCalled) - } - return nil -} diff --git a/backend/internal/services/dashboard_service.go b/backend/internal/services/dashboard_service.go index f4bc08de53..653ef8b09b 100644 --- a/backend/internal/services/dashboard_service.go +++ b/backend/internal/services/dashboard_service.go @@ -6,7 +6,6 @@ import ( "log/slog" "net/http" "sort" - "strings" "time" "github.com/getarcaneapp/arcane/backend/internal/database" @@ -445,9 +444,8 @@ func (s *DashboardService) buildEnvironmentOverviewInternal( } if snapshotErr != nil { - message := snapshotErr.Error() overview.SnapshotState = dashboardtypes.EnvironmentSnapshotStateError - overview.SnapshotError = &message + overview.SnapshotError = new(snapshotErr.Error()) return overview } @@ -634,7 +632,7 @@ func (s *DashboardService) getPendingResourceUpdatesCountInternal(ctx context.Co func filterStandaloneDockerContainersInternal(containers []dockercontainer.Summary) []dockercontainer.Summary { filtered := make([]dockercontainer.Summary, 0, len(containers)) for _, c := range containers { - if strings.TrimSpace(c.Labels["com.docker.compose.project"]) != "" { + if dockerutils.ComposeProjectLabel(c.Labels) != "" { continue } filtered = append(filtered, c) diff --git a/backend/internal/services/environment_service.go b/backend/internal/services/environment_service.go index df226c2bc5..f283a41c35 100644 --- a/backend/internal/services/environment_service.go +++ b/backend/internal/services/environment_service.go @@ -1176,17 +1176,15 @@ func (s *EnvironmentService) logGeneratedMTLSEventsInternal(ctx context.Context, } } if assets.CertIssued { - resourceType := "environment" envIDCopy := envID - envNameCopy := envName if _, err := s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeEnvironmentMTLSCertIssued, Severity: models.EventSeverityInfo, Title: "Edge mTLS certificate issued", Description: fmt.Sprintf("Arcane issued an edge mTLS client certificate for environment '%s'", envName), - ResourceType: &resourceType, + ResourceType: new("environment"), ResourceID: &envIDCopy, - ResourceName: &envNameCopy, + ResourceName: new(envName), EnvironmentID: &envIDCopy, Metadata: models.JSON{"kind": "client"}, }); err != nil { diff --git a/backend/internal/services/environment_service_test.go b/backend/internal/services/environment_service_test.go index b9c1f650ca..5a54ad497c 100644 --- a/backend/internal/services/environment_service_test.go +++ b/backend/internal/services/environment_service_test.go @@ -266,7 +266,6 @@ func TestEnvironmentService_SyncRepositoriesToEnvironment_UsesAgentHeaders(t *te require.NoError(t, db.AutoMigrate(&models.GitRepository{})) svc := NewEnvironmentService(db, nil, nil, nil, nil, nil) - description := "test repo" createTestGitRepository(t, db, models.GitRepository{ BaseModel: models.BaseModel{ID: "repo-1", CreatedAt: time.Now()}, Name: "repo-1", @@ -275,7 +274,7 @@ func TestEnvironmentService_SyncRepositoriesToEnvironment_UsesAgentHeaders(t *te Username: "arcane", Token: encryptSecretForTest(t, "repo-token"), Enabled: true, - Description: &description, + Description: new("test repo"), }) accessToken := "token-1" @@ -934,9 +933,7 @@ func TestEnvironmentService_TestConnection_RejectsInvalidCustomURL(t *testing.T) svc := NewEnvironmentService(db, nil, nil, nil, nil, nil) createTestEnvironment(t, db, "env-1", "http://example.com", nil) - customURL := "ftp://example.com" - - status, err := svc.TestConnection(ctx, "env-1", &customURL) + status, err := svc.TestConnection(ctx, "env-1", new("ftp://example.com")) require.Error(t, err) require.Equal(t, "offline", status) require.Contains(t, err.Error(), "invalid environment API URL") diff --git a/backend/internal/services/git_repository_service.go b/backend/internal/services/git_repository_service.go index e17d693c71..d44c97ae61 100644 --- a/backend/internal/services/git_repository_service.go +++ b/backend/internal/services/git_repository_service.go @@ -14,7 +14,6 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/gitops" "gorm.io/gorm" ) @@ -39,25 +38,14 @@ func (s *GitRepositoryService) GetRepositoriesPaginated(ctx context.Context, par var repositories []models.GitRepository q := s.db.WithContext(ctx).Model(&models.GitRepository{}) - if term := strings.TrimSpace(params.Search); term != "" { - searchPattern := "%" + term + "%" - q = q.Where( - "name LIKE ? OR url LIKE ? OR COALESCE(description, '') LIKE ?", - searchPattern, searchPattern, searchPattern, - ) - } + q = pagination.ApplyLikeSearch(q, params.Search, "name LIKE ? OR url LIKE ? OR COALESCE(description, '') LIKE ?") q = pagination.ApplyBooleanFilter(q, "enabled", params.Filters["enabled"]) q = pagination.ApplyFilter(q, "auth_type", params.Filters["authType"]) - paginationResp, err := pagination.PaginateAndSortDB(params, q, &repositories) + out, paginationResp, err := pagination.PaginateSortAndMapDB[models.GitRepository, gitops.GitRepository](params, q, &repositories) if err != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to paginate git repositories: %w", err) - } - - out, mapErr := mapper.MapSlice[models.GitRepository, gitops.GitRepository](repositories) - if mapErr != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to map repositories: %w", mapErr) + return nil, pagination.Response{}, fmt.Errorf("failed to list git repositories: %w", err) } return out, paginationResp, nil diff --git a/backend/internal/services/git_repository_service_test.go b/backend/internal/services/git_repository_service_test.go index 007dcb4ec9..824707d774 100644 --- a/backend/internal/services/git_repository_service_test.go +++ b/backend/internal/services/git_repository_service_test.go @@ -75,7 +75,7 @@ func TestGitRepositoryService_UpdateRepository_RejectsURLChangeWhenStoredTokenWo }) _, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://attacker.tld/repo.git"), + URL: new("https://attacker.tld/repo.git"), }, models.User{}) require.Error(t, err) @@ -100,7 +100,7 @@ func TestGitRepositoryService_UpdateRepository_RejectsURLChangeWhenStoredSSHKeyW }) _, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("git@attacker.tld:acme/private.git"), + URL: new("git@attacker.tld:acme/private.git"), }, models.User{}) require.Error(t, err) @@ -127,7 +127,7 @@ func TestGitRepositoryService_UpdateRepository_RejectsURLChangeWhenStoredTokenAn }) _, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://attacker.tld/repo.git"), + URL: new("https://attacker.tld/repo.git"), }, models.User{}) require.Error(t, err) @@ -149,8 +149,8 @@ func TestGitRepositoryService_UpdateRepository_AllowsURLChangeWhenTokenIsResuppl }) updated, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://github.com/acme/private-rotated.git"), - Token: gitRepositoryStringPtrInternal("ghp_new_token"), + URL: new("https://github.com/acme/private-rotated.git"), + Token: new("ghp_new_token"), }, models.User{}) require.NoError(t, err) @@ -171,8 +171,8 @@ func TestGitRepositoryService_UpdateRepository_AllowsURLChangeWhenTokenIsCleared }) updated, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://github.com/acme/public.git"), - Token: gitRepositoryStringPtrInternal(""), + URL: new("https://github.com/acme/public.git"), + Token: new(""), }, models.User{}) require.NoError(t, err) @@ -191,8 +191,8 @@ func TestGitRepositoryService_UpdateRepository_AllowsSameURLWithoutCredentialRes }) updated, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://github.com/acme/private.git"), - Username: gitRepositoryStringPtrInternal("deploy-bot"), + URL: new("https://github.com/acme/private.git"), + Username: new("deploy-bot"), }, models.User{}) require.NoError(t, err) diff --git a/backend/internal/services/gitops_sync_service.go b/backend/internal/services/gitops_sync_service.go index 25b9150721..504718f86b 100644 --- a/backend/internal/services/gitops_sync_service.go +++ b/backend/internal/services/gitops_sync_service.go @@ -1389,14 +1389,12 @@ func (s *GitOpsSyncService) findUniqueProjectDirectoryCandidateInternal(ctx cont } func (s *GitOpsSyncService) createRecoveredProjectFromDirectoryInternal(ctx context.Context, sync *models.GitOpsSync, projectPath string) (*models.Project, error) { - dirName := filepath.Base(projectPath) - reason := "Project recovered from existing GitOps-managed directory" project := &models.Project{ Name: sync.ProjectName, - DirName: &dirName, + DirName: new(filepath.Base(projectPath)), Path: projectPath, Status: models.ProjectStatusUnknown, - StatusReason: &reason, + StatusReason: new("Project recovered from existing GitOps-managed directory"), ServiceCount: 0, RunningCount: 0, GitOpsManagedBy: &sync.ID, diff --git a/backend/internal/services/image_service.go b/backend/internal/services/image_service.go index c27547968c..1d75128191 100644 --- a/backend/internal/services/image_service.go +++ b/backend/internal/services/image_service.go @@ -803,7 +803,7 @@ func (s *ImageService) BuildProjectIDMap(ctx context.Context, containers []conta if c.Labels == nil { continue } - if projectName := c.Labels["com.docker.compose.project"]; projectName != "" { + if projectName := dockerutils.ComposeProjectLabel(c.Labels); projectName != "" { projectNameSet[projectName] = struct{}{} } } @@ -830,10 +830,7 @@ func buildUsageMapInternal(containers []container.Summary, projectIDByName map[s continue } - projectName := "" - if c.Labels != nil { - projectName = c.Labels["com.docker.compose.project"] - } + projectName := dockerutils.ComposeProjectLabel(c.Labels) if projectName != "" { projectID := projectIDByName[projectName] diff --git a/backend/internal/services/job_service_test.go b/backend/internal/services/job_service_test.go index 737155467f..b4facc479a 100644 --- a/backend/internal/services/job_service_test.go +++ b/backend/internal/services/job_service_test.go @@ -102,9 +102,8 @@ func TestJobService_UpdateJobSchedules_ReschedulesChangedJob(t *testing.T) { scheduler := newFakeJobSchedulerInternal("image-polling", "auto-update") jobSvc.SetScheduler(ctx, scheduler) - nextPollingInterval := "0 */10 * * * *" _, err = jobSvc.UpdateJobSchedules(ctx, jobschedule.Update{ - PollingInterval: &nextPollingInterval, + PollingInterval: new("0 */10 * * * *"), }) require.NoError(t, err) @@ -126,9 +125,8 @@ func TestJobService_UpdateJobSchedules_UsesLifecycleContextForReschedule(t *test scheduler := newFakeJobSchedulerInternal("image-polling") jobSvc.SetScheduler(lifecycleCtx, scheduler) - nextPollingInterval := "0 */10 * * * *" _, err = jobSvc.UpdateJobSchedules(requestCtx, jobschedule.Update{ - PollingInterval: &nextPollingInterval, + PollingInterval: new("0 */10 * * * *"), }) require.NoError(t, err) @@ -150,9 +148,8 @@ func TestJobService_UpdateJobSchedules_SkipsManagerOnlyJobsInAgentMode(t *testin scheduler := newFakeJobSchedulerInternal("environment-health") jobSvc.SetScheduler(ctx, scheduler) - nextHealthInterval := "0 */5 * * * *" _, err = jobSvc.UpdateJobSchedules(ctx, jobschedule.Update{ - EnvironmentHealthInterval: &nextHealthInterval, + EnvironmentHealthInterval: new("0 */5 * * * *"), }) require.NoError(t, err) diff --git a/backend/internal/services/log_stream_util.go b/backend/internal/services/log_stream_util.go deleted file mode 100644 index 64ada4488c..0000000000 --- a/backend/internal/services/log_stream_util.go +++ /dev/null @@ -1,112 +0,0 @@ -package services - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "log/slog" - "strings" - - "github.com/moby/moby/api/pkg/stdcopy" -) - -// streamMultiplexedLogs demultiplexes a Docker log stream (stdout/stderr) -// and sends lines to logsChan. Used by both container and swarm service logs. -func streamMultiplexedLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string) error { - stdoutReader, stdoutWriter := io.Pipe() - stderrReader, stderrWriter := io.Pipe() - - go func() { - defer func() { _ = stdoutWriter.Close() }() - defer func() { _ = stderrWriter.Close() }() - _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, logs) - if err != nil && !errors.Is(err, io.EOF) { - slog.Error("error demultiplexing logs", "error", err) - } - }() - - done := make(chan error, 2) - - go func() { - done <- readLogsFromReader(ctx, stdoutReader, logsChan, "stdout") - }() - - go func() { - done <- readLogsFromReader(ctx, stderrReader, logsChan, "stderr") - }() - - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-done: - if err != nil && !errors.Is(err, io.EOF) { - return err - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-done: - return nil - } - } -} - -// readLogsFromReader reads logs line by line from a reader and sends them to logsChan. -func readLogsFromReader(ctx context.Context, reader io.Reader, logsChan chan<- string, source string) error { - scanner := bufio.NewScanner(reader) - - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - line := scanner.Text() - if line != "" { - if source == "stderr" { - line = "[STDERR] " + line - } - - select { - case logsChan <- line: - case <-ctx.Done(): - return ctx.Err() - } - } - } - } - - return scanner.Err() -} - -// readAllLogs reads all logs at once (non-follow mode) and sends them to logsChan. -func readAllLogs(logs io.ReadCloser, logsChan chan<- string) error { - stdoutBuf := &strings.Builder{} - stderrBuf := &strings.Builder{} - - _, err := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) - if err != nil && !errors.Is(err, io.EOF) { - return fmt.Errorf("failed to demultiplex logs: %w", err) - } - - if stdoutBuf.Len() > 0 { - lines := strings.SplitSeq(strings.TrimRight(stdoutBuf.String(), "\n"), "\n") - for line := range lines { - if line != "" { - logsChan <- line - } - } - } - - if stderrBuf.Len() > 0 { - lines := strings.SplitSeq(strings.TrimRight(stderrBuf.String(), "\n"), "\n") - for line := range lines { - if line != "" { - logsChan <- "[STDERR] " + line - } - } - } - - return nil -} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index df29bb8d9d..e51b808337 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -553,41 +553,13 @@ func (s *NotificationService) sendVulnerabilityNotificationForTargetInternal(ctx continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendDiscordVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendEmailVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendTelegramVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendSignalVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendSlackVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendNtfyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendPushoverVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendGotifyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendMatrixVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendGenericVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, payload, setting.Config, s.vulnerabilityNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errors, setting.Provider, sendErr) s.logNotification(ctx, setting.Provider, payload.ImageName, status, errMsg, models.JSON{ "cveId": payload.CVEID, @@ -987,58 +959,20 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI FixedVersion: "7 fixable vulnerability record(s)", PkgName: "CVE-2025-1234, CVE-2025-5678, CVE-2026-0001", } - switch provider { - case models.NotificationProviderDiscord: - return s.sendDiscordVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderEmail: - return s.sendEmailVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderTelegram: - return s.sendTelegramVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSignal: - return s.sendSignalVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSlack: - return s.sendSlackVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderNtfy: - return s.sendNtfyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderPushover: - return s.sendPushoverVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGotify: - return s.sendGotifyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderMatrix: - return s.sendMatrixVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGeneric: - return s.sendGenericVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, payload, setting.Config, s.vulnerabilityNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } if testType == notificationTestTypeAutoHeal { testContainerName := "test-container" - switch provider { - case models.NotificationProviderDiscord: - return s.sendDiscordAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderEmail: - return s.sendEmailAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderTelegram: - return s.sendTelegramAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderSignal: - return s.sendSignalAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderSlack: - return s.sendSlackAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderNtfy: - return s.sendNtfyAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderPushover: - return s.sendPushoverAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderGotify: - return s.sendGotifyAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderMatrix: - return s.sendMatrixAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderGeneric: - return s.sendGenericAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, testContainerName, setting.Config, s.autoHealNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } if testType == notificationTestTypePruneReport { @@ -1056,30 +990,11 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI Errors: []string{}, } - switch provider { - case models.NotificationProviderDiscord: - return s.sendDiscordPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderEmail: - return s.sendEmailPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderTelegram: - return s.sendTelegramPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSignal: - return s.sendSignalPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSlack: - return s.sendSlackPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderNtfy: - return s.sendNtfyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderPushover: - return s.sendPushoverPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGotify: - return s.sendGotifyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderMatrix: - return s.sendMatrixPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGeneric: - return s.sendGenericPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, result, setting.Config, s.pruneReportNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } testUpdate := &imageupdate.Response{ @@ -1119,30 +1034,11 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI ResponseTimeMs: 95, }, } - switch provider { - case models.NotificationProviderDiscord: - return s.sendBatchDiscordNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderEmail: - return s.sendBatchEmailNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderTelegram: - return s.sendBatchTelegramNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderSignal: - return s.sendBatchSignalNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderSlack: - return s.sendBatchSlackNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderNtfy: - return s.sendBatchNtfyNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderPushover: - return s.sendBatchPushoverNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderGotify: - return s.sendBatchGotifyNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderMatrix: - return s.sendBatchMatrixNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderGeneric: - return s.sendBatchGenericNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, testUpdates, setting.Config, s.batchImageUpdateNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } imageRef := "nginx:latest" @@ -1179,6 +1075,129 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI } } +type notificationProviderSenderInternal[T any] func(context.Context, string, T, models.JSON) error + +type notificationProviderSendersInternal[T any] struct { + Discord notificationProviderSenderInternal[T] + Email notificationProviderSenderInternal[T] + Telegram notificationProviderSenderInternal[T] + Signal notificationProviderSenderInternal[T] + Slack notificationProviderSenderInternal[T] + Ntfy notificationProviderSenderInternal[T] + Pushover notificationProviderSenderInternal[T] + Gotify notificationProviderSenderInternal[T] + Matrix notificationProviderSenderInternal[T] + Generic notificationProviderSenderInternal[T] +} + +func (s *NotificationService) vulnerabilityNotificationSendersInternal() notificationProviderSendersInternal[VulnerabilityNotificationPayload] { + return notificationProviderSendersInternal[VulnerabilityNotificationPayload]{ + Discord: s.sendDiscordVulnerabilityNotification, + Email: s.sendEmailVulnerabilityNotification, + Telegram: s.sendTelegramVulnerabilityNotification, + Signal: s.sendSignalVulnerabilityNotification, + Slack: s.sendSlackVulnerabilityNotification, + Ntfy: s.sendNtfyVulnerabilityNotification, + Pushover: s.sendPushoverVulnerabilityNotification, + Gotify: s.sendGotifyVulnerabilityNotification, + Matrix: s.sendMatrixVulnerabilityNotification, + Generic: s.sendGenericVulnerabilityNotification, + } +} + +func (s *NotificationService) batchImageUpdateNotificationSendersInternal() notificationProviderSendersInternal[map[string]*imageupdate.Response] { + return notificationProviderSendersInternal[map[string]*imageupdate.Response]{ + Discord: s.sendBatchDiscordNotification, + Email: s.sendBatchEmailNotification, + Telegram: s.sendBatchTelegramNotification, + Signal: s.sendBatchSignalNotification, + Slack: s.sendBatchSlackNotification, + Ntfy: s.sendBatchNtfyNotification, + Pushover: s.sendBatchPushoverNotification, + Gotify: s.sendBatchGotifyNotification, + Matrix: s.sendBatchMatrixNotification, + Generic: s.sendBatchGenericNotification, + } +} + +func (s *NotificationService) pruneReportNotificationSendersInternal() notificationProviderSendersInternal[*system.PruneAllResult] { + return notificationProviderSendersInternal[*system.PruneAllResult]{ + Discord: s.sendDiscordPruneNotification, + Email: s.sendEmailPruneNotification, + Telegram: s.sendTelegramPruneNotification, + Signal: s.sendSignalPruneNotification, + Slack: s.sendSlackPruneNotification, + Ntfy: s.sendNtfyPruneNotification, + Pushover: s.sendPushoverPruneNotification, + Gotify: s.sendGotifyPruneNotification, + Matrix: s.sendMatrixPruneNotification, + Generic: s.sendGenericPruneNotification, + } +} + +func (s *NotificationService) autoHealNotificationSendersInternal() notificationProviderSendersInternal[string] { + return notificationProviderSendersInternal[string]{ + Discord: s.sendDiscordAutoHealNotification, + Email: s.sendEmailAutoHealNotification, + Telegram: s.sendTelegramAutoHealNotification, + Signal: s.sendSignalAutoHealNotification, + Slack: s.sendSlackAutoHealNotification, + Ntfy: s.sendNtfyAutoHealNotification, + Pushover: s.sendPushoverAutoHealNotification, + Gotify: s.sendGotifyAutoHealNotification, + Matrix: s.sendMatrixAutoHealNotification, + Generic: s.sendGenericAutoHealNotification, + } +} + +func sendProviderNotificationInternal[T any]( + ctx context.Context, + provider models.NotificationProvider, + environmentName string, + payload T, + config models.JSON, + senders notificationProviderSendersInternal[T], +) (bool, error) { + switch provider { + case models.NotificationProviderDiscord: + return true, senders.Discord(ctx, environmentName, payload, config) + case models.NotificationProviderEmail: + return true, senders.Email(ctx, environmentName, payload, config) + case models.NotificationProviderTelegram: + return true, senders.Telegram(ctx, environmentName, payload, config) + case models.NotificationProviderSignal: + return true, senders.Signal(ctx, environmentName, payload, config) + case models.NotificationProviderSlack: + return true, senders.Slack(ctx, environmentName, payload, config) + case models.NotificationProviderNtfy: + return true, senders.Ntfy(ctx, environmentName, payload, config) + case models.NotificationProviderPushover: + return true, senders.Pushover(ctx, environmentName, payload, config) + case models.NotificationProviderGotify: + return true, senders.Gotify(ctx, environmentName, payload, config) + case models.NotificationProviderMatrix: + return true, senders.Matrix(ctx, environmentName, payload, config) + case models.NotificationProviderGeneric: + return true, senders.Generic(ctx, environmentName, payload, config) + default: + return false, nil + } +} + +func collectNotificationSendResultInternal(errors *[]string, provider models.NotificationProvider, sendErr error) (string, *string) { + if sendErr == nil { + return "success", nil + } + + msg := sendErr.Error() + *errors = append(*errors, fmt.Sprintf("%s: %s", provider, msg)) + return "failed", &msg +} + +func unknownNotificationProviderErrorInternal(provider models.NotificationProvider) error { + return fmt.Errorf("unknown provider: %s", provider) +} + func (s *NotificationService) sendTestEmail(ctx context.Context, environmentName string, config models.JSON) error { var emailConfig models.EmailConfig configBytes, err := json.Marshal(config) @@ -1338,41 +1357,13 @@ func (s *NotificationService) sendBatchImageUpdateNotificationForTargetInternal( continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendBatchDiscordNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendBatchEmailNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendBatchTelegramNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendBatchSignalNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendBatchSlackNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendBatchNtfyNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendBatchPushoverNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendBatchGotifyNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendBatchMatrixNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendBatchGenericNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, updatesWithChanges, setting.Config, s.batchImageUpdateNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errors, setting.Provider, sendErr) imageRefs := make([]string, 0, len(updatesWithChanges)) for ref := range updatesWithChanges { @@ -1394,22 +1385,12 @@ func (s *NotificationService) sendBatchImageUpdateNotificationForTargetInternal( } func (s *NotificationService) sendBatchDiscordNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var discordConfig models.DiscordConfig - configBytes, err := json.Marshal(config) + discordConfig, err := decodeNotificationConfigInternal[models.DiscordConfig](config, "discord") if err != nil { - return fmt.Errorf("failed to marshal discord config: %w", err) - } - if err := json.Unmarshal(configBytes, &discordConfig); err != nil { - return fmt.Errorf("failed to unmarshal discord config: %w", err) + return err } - - // Decrypt token - if discordConfig.Token != "" { - decrypted, err := crypto.Decrypt(discordConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - discordConfig.Token = decrypted + if err := decryptStringCredentialInternal(&discordConfig.Token); err != nil { + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1744,26 +1725,9 @@ func (s *NotificationService) sendBatchSignalNotification(ctx context.Context, e } func (s *NotificationService) sendSlackNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "Slack", true) if err != nil { - return fmt.Errorf("failed to marshal Slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal Slack config: %w", err) - } - - if slackConfig.Token == "" { - return fmt.Errorf("slack token not configured") - } - - // Decrypt token if encrypted - if slackConfig.Token != "" { - decrypted, err := crypto.Decrypt(slackConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - slackConfig.Token = decrypted + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -1781,26 +1745,9 @@ func (s *NotificationService) sendSlackNotification(ctx context.Context, environ } func (s *NotificationService) sendSlackContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "Slack", true) if err != nil { - return fmt.Errorf("failed to marshal Slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal Slack config: %w", err) - } - - if slackConfig.Token == "" { - return fmt.Errorf("slack token not configured") - } - - // Decrypt token if encrypted - if slackConfig.Token != "" { - decrypted, err := crypto.Decrypt(slackConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - slackConfig.Token = decrypted + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1820,22 +1767,9 @@ func (s *NotificationService) sendSlackContainerUpdateNotification(ctx context.C } func (s *NotificationService) sendBatchSlackNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "slack", false) if err != nil { - return fmt.Errorf("failed to marshal slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal slack config: %w", err) - } - - // Decrypt token - if slackConfig.Token != "" { - decrypted, err := crypto.Decrypt(slackConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - slackConfig.Token = decrypted + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1852,26 +1786,9 @@ func (s *NotificationService) sendBatchSlackNotification(ctx context.Context, en } func (s *NotificationService) sendNtfyNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "Ntfy", true) if err != nil { - return fmt.Errorf("failed to marshal Ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Ntfy config: %w", err) - } - - if ntfyConfig.Topic == "" { - return fmt.Errorf("ntfy topic is required") - } - - // Decrypt password if encrypted - if ntfyConfig.Password != "" { - decrypted, err := crypto.Decrypt(ntfyConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - ntfyConfig.Password = decrypted + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -1889,26 +1806,9 @@ func (s *NotificationService) sendNtfyNotification(ctx context.Context, environm } func (s *NotificationService) sendNtfyContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "Ntfy", true) if err != nil { - return fmt.Errorf("failed to marshal Ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Ntfy config: %w", err) - } - - if ntfyConfig.Topic == "" { - return fmt.Errorf("ntfy topic is required") - } - - // Decrypt password if encrypted - if ntfyConfig.Password != "" { - decrypted, err := crypto.Decrypt(ntfyConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - ntfyConfig.Password = decrypted + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1928,22 +1828,9 @@ func (s *NotificationService) sendNtfyContainerUpdateNotification(ctx context.Co } func (s *NotificationService) sendBatchNtfyNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "ntfy", false) if err != nil { - return fmt.Errorf("failed to marshal ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal ntfy config: %w", err) - } - - // Decrypt password if encrypted - if ntfyConfig.Password != "" { - decrypted, err := crypto.Decrypt(ntfyConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - ntfyConfig.Password = decrypted + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1960,13 +1847,9 @@ func (s *NotificationService) sendBatchNtfyNotification(ctx context.Context, env } func (s *NotificationService) sendPushoverNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "Pushover") if err != nil { - return fmt.Errorf("failed to marshal Pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal Pushover config: %w", err) + return err } if pushoverConfig.Token == "" { @@ -1976,14 +1859,6 @@ func (s *NotificationService) sendPushoverNotification(ctx context.Context, envi return fmt.Errorf("pushover user key not configured") } - if pushoverConfig.Token != "" { - decrypted, err := crypto.Decrypt(pushoverConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - pushoverConfig.Token = decrypted - } - message := notifications.BuildImageUpdateNotificationMessage( notifications.MessageFormatPlain, environmentName, @@ -1999,13 +1874,9 @@ func (s *NotificationService) sendPushoverNotification(ctx context.Context, envi } func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "Pushover") if err != nil { - return fmt.Errorf("failed to marshal Pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal Pushover config: %w", err) + return err } if pushoverConfig.Token == "" { @@ -2015,14 +1886,6 @@ func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx contex return fmt.Errorf("pushover user key not configured") } - if pushoverConfig.Token != "" { - decrypted, err := crypto.Decrypt(pushoverConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - pushoverConfig.Token = decrypted - } - message := notifications.BuildContainerUpdateNotificationMessage( notifications.MessageFormatPlain, environmentName, @@ -2040,21 +1903,9 @@ func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx contex } func (s *NotificationService) sendBatchPushoverNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "pushover") if err != nil { - return fmt.Errorf("failed to marshal pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal pushover config: %w", err) - } - - if pushoverConfig.Token != "" { - decrypted, err := crypto.Decrypt(pushoverConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - pushoverConfig.Token = decrypted + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2329,33 +2180,11 @@ func (s *NotificationService) sendSignalVulnerabilityNotification(ctx context.Co } func (s *NotificationService) sendSlackVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "Slack", true) if err != nil { - return fmt.Errorf("failed to marshal Slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal Slack config: %w", err) - } - if slackConfig.Token == "" { - return fmt.Errorf("slack token not configured") - } - if slackConfig.Token != "" { - decrypted, err := crypto.Decrypt(slackConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - slackConfig.Token = decrypted + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatSlack, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatSlack, environmentName, payload) if err := notifications.SendSlack(ctx, slackConfig, message); err != nil { return fmt.Errorf("failed to send Slack notification: %w", err) } @@ -2363,33 +2192,11 @@ func (s *NotificationService) sendSlackVulnerabilityNotification(ctx context.Con } func (s *NotificationService) sendNtfyVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "Ntfy", true) if err != nil { - return fmt.Errorf("failed to marshal Ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Ntfy config: %w", err) - } - if ntfyConfig.Topic == "" { - return fmt.Errorf("ntfy topic is required") - } - if ntfyConfig.Password != "" { - decrypted, err := crypto.Decrypt(ntfyConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - ntfyConfig.Password = decrypted + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if err := notifications.SendNtfy(ctx, ntfyConfig, message); err != nil { return fmt.Errorf("failed to send Ntfy notification: %w", err) } @@ -2397,33 +2204,14 @@ func (s *NotificationService) sendNtfyVulnerabilityNotification(ctx context.Cont } func (s *NotificationService) sendPushoverVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "Pushover") if err != nil { - return fmt.Errorf("failed to marshal Pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal Pushover config: %w", err) + return err } if pushoverConfig.Token == "" || pushoverConfig.User == "" { return fmt.Errorf("pushover token or user not configured") } - if pushoverConfig.Token != "" { - decrypted, err := crypto.Decrypt(pushoverConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - pushoverConfig.Token = decrypted - } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if pushoverConfig.Title == "" { pushoverConfig.Title = notifications.BuildEmailSubject(environmentName, "Daily Vulnerability Summary") } @@ -2434,30 +2222,11 @@ func (s *NotificationService) sendPushoverVulnerabilityNotification(ctx context. } func (s *NotificationService) sendGotifyVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "Gotify") if err != nil { - return fmt.Errorf("failed to marshal Gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Gotify config: %w", err) - } - if gotifyConfig.Token != "" { - decrypted, err := crypto.Decrypt(gotifyConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - gotifyConfig.Token = decrypted + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if gotifyConfig.Title == "" { gotifyConfig.Title = notifications.BuildEmailSubject(environmentName, "Daily Vulnerability Summary") } @@ -2468,30 +2237,11 @@ func (s *NotificationService) sendGotifyVulnerabilityNotification(ctx context.Co } func (s *NotificationService) sendMatrixVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - if matrixConfig.Password != "" { - decrypted, err := crypto.Decrypt(matrixConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - matrixConfig.Password = decrypted + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if err := notifications.SendMatrix(ctx, matrixConfig, message); err != nil { return fmt.Errorf("failed to send Matrix notification: %w", err) } @@ -2555,21 +2305,9 @@ func (s *NotificationService) sendBatchGenericNotification(ctx context.Context, } func (s *NotificationService) sendGotifyNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "Gotify") if err != nil { - return fmt.Errorf("failed to marshal Gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Gotify config: %w", err) - } - - if gotifyConfig.Token != "" { - decrypted, err := crypto.Decrypt(gotifyConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - gotifyConfig.Token = decrypted + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -2587,21 +2325,9 @@ func (s *NotificationService) sendGotifyNotification(ctx context.Context, enviro } func (s *NotificationService) sendGotifyContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "Gotify") if err != nil { - return fmt.Errorf("failed to marshal Gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Gotify config: %w", err) - } - - if gotifyConfig.Token != "" { - decrypted, err := crypto.Decrypt(gotifyConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - gotifyConfig.Token = decrypted + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2621,21 +2347,9 @@ func (s *NotificationService) sendGotifyContainerUpdateNotification(ctx context. } func (s *NotificationService) sendBatchGotifyNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "gotify") if err != nil { - return fmt.Errorf("failed to marshal gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal gotify config: %w", err) - } - - if gotifyConfig.Token != "" { - decrypted, err := crypto.Decrypt(gotifyConfig.Token) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - gotifyConfig.Token = decrypted + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2652,21 +2366,9 @@ func (s *NotificationService) sendBatchGotifyNotification(ctx context.Context, e } func (s *NotificationService) sendMatrixNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - - if matrixConfig.Password != "" { - decrypted, err := crypto.Decrypt(matrixConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - matrixConfig.Password = decrypted + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -2684,21 +2386,9 @@ func (s *NotificationService) sendMatrixNotification(ctx context.Context, enviro } func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - - if matrixConfig.Password != "" { - decrypted, err := crypto.Decrypt(matrixConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - matrixConfig.Password = decrypted + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2718,21 +2408,9 @@ func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context. } func (s *NotificationService) sendBatchMatrixNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - - if matrixConfig.Password != "" { - decrypted, err := crypto.Decrypt(matrixConfig.Password) - if err != nil { - return fmt.Errorf("failed to decrypt notification credential: %w", err) - } - matrixConfig.Password = decrypted + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2792,41 +2470,13 @@ func (s *NotificationService) sendPruneReportNotificationForTargetInternal(ctx c continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendDiscordPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendEmailPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendTelegramPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendSignalPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendSlackPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendNtfyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendPushoverPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendGotifyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendMatrixPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendGenericPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, result, setting.Config, s.pruneReportNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errors, setting.Provider, sendErr) s.logNotification(ctx, setting.Provider, "System Prune Report", status, errMsg, models.JSON{ "spaceReclaimed": result.SpaceReclaimed, @@ -2859,19 +2509,6 @@ func pruneResultHasChangesInternal(result *system.PruneAllResult) bool { len(result.NetworksDeleted) > 0 } -func (s *NotificationService) formatBytesInternal(bytes uint64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - func (s *NotificationService) sendDiscordPruneNotification(ctx context.Context, environmentName string, result *system.PruneAllResult, config models.JSON) error { var discordConfig models.DiscordConfig if err := s.unmarshalConfigInternal(config, &discordConfig); err != nil { @@ -2941,7 +2578,7 @@ func (s *NotificationService) sendEmailPruneNotification(ctx context.Context, en return fmt.Errorf("failed to render email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("System Prune Report: %s Reclaimed", s.formatBytesInternal(result.SpaceReclaimed))) + subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("System Prune Report: %s Reclaimed", notifications.FormatBytes(result.SpaceReclaimed))) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -2956,11 +2593,11 @@ func (s *NotificationService) renderPruneReportEmailTemplate(environmentName str "LogoURL": logoURL, "AppURL": appURL, "Environment": environmentName, - "TotalSpaceReclaimed": s.formatBytesInternal(result.SpaceReclaimed), - "ContainerSpaceReclaimed": s.formatBytesInternal(result.ContainerSpaceReclaimed), - "ImageSpaceReclaimed": s.formatBytesInternal(result.ImageSpaceReclaimed), - "VolumeSpaceReclaimed": s.formatBytesInternal(result.VolumeSpaceReclaimed), - "BuildCacheSpaceReclaimed": s.formatBytesInternal(result.BuildCacheSpaceReclaimed), + "TotalSpaceReclaimed": notifications.FormatBytes(result.SpaceReclaimed), + "ContainerSpaceReclaimed": notifications.FormatBytes(result.ContainerSpaceReclaimed), + "ImageSpaceReclaimed": notifications.FormatBytes(result.ImageSpaceReclaimed), + "VolumeSpaceReclaimed": notifications.FormatBytes(result.VolumeSpaceReclaimed), + "BuildCacheSpaceReclaimed": notifications.FormatBytes(result.BuildCacheSpaceReclaimed), "Time": time.Now().Format(time.RFC1123), } @@ -3095,41 +2732,13 @@ func (s *NotificationService) sendAutoHealNotificationForTargetInternal(ctx cont continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendDiscordAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendEmailAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendTelegramAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendSignalAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendSlackAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendNtfyAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendPushoverAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendGotifyAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendMatrixAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendGenericAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, containerName, setting.Config, s.autoHealNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errs = append(errs, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errs, setting.Provider, sendErr) s.logNotification(ctx, setting.Provider, containerName, status, errMsg, models.JSON{ "containerID": containerID, @@ -3286,6 +2895,18 @@ func (s *NotificationService) unmarshalConfigInternal(config models.JSON, dest a return nil } +func decodeNotificationConfigInternal[T any](config models.JSON, providerName string) (T, error) { + var out T + configBytes, err := json.Marshal(config) + if err != nil { + return out, fmt.Errorf("failed to marshal %s config: %w", providerName, err) + } + if err := json.Unmarshal(configBytes, &out); err != nil { + return out, fmt.Errorf("failed to unmarshal %s config: %w", providerName, err) + } + return out, nil +} + func (s *NotificationService) validateEmailConfigInternal(config *models.EmailConfig) error { if config.SMTPHost == "" || config.SMTPPort == 0 { return fmt.Errorf("SMTP host or port not configured") @@ -3296,6 +2917,92 @@ func (s *NotificationService) validateEmailConfigInternal(config *models.EmailCo return nil } +func decryptStringCredentialInternal(value *string) error { + if *value == "" { + return nil + } + + decrypted, err := crypto.Decrypt(*value) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) + } + *value = decrypted + return nil +} + +func prepareSlackConfigInternal(config models.JSON, providerName string, requireToken bool) (models.SlackConfig, error) { + slackConfig, err := decodeNotificationConfigInternal[models.SlackConfig](config, providerName) + if err != nil { + return models.SlackConfig{}, err + } + if requireToken && slackConfig.Token == "" { + return models.SlackConfig{}, fmt.Errorf("slack token not configured") + } + if err := decryptStringCredentialInternal(&slackConfig.Token); err != nil { + return models.SlackConfig{}, err + } + return slackConfig, nil +} + +func prepareNtfyConfigInternal(config models.JSON, providerName string, requireTopic bool) (models.NtfyConfig, error) { + ntfyConfig, err := decodeNotificationConfigInternal[models.NtfyConfig](config, providerName) + if err != nil { + return models.NtfyConfig{}, err + } + if requireTopic && ntfyConfig.Topic == "" { + return models.NtfyConfig{}, fmt.Errorf("ntfy topic is required") + } + if err := decryptStringCredentialInternal(&ntfyConfig.Password); err != nil { + return models.NtfyConfig{}, err + } + return ntfyConfig, nil +} + +func preparePushoverConfigInternal(config models.JSON, providerName string) (models.PushoverConfig, error) { + pushoverConfig, err := decodeNotificationConfigInternal[models.PushoverConfig](config, providerName) + if err != nil { + return models.PushoverConfig{}, err + } + if err := decryptStringCredentialInternal(&pushoverConfig.Token); err != nil { + return models.PushoverConfig{}, err + } + return pushoverConfig, nil +} + +func prepareGotifyConfigInternal(config models.JSON, providerName string) (models.GotifyConfig, error) { + gotifyConfig, err := decodeNotificationConfigInternal[models.GotifyConfig](config, providerName) + if err != nil { + return models.GotifyConfig{}, err + } + if err := decryptStringCredentialInternal(&gotifyConfig.Token); err != nil { + return models.GotifyConfig{}, err + } + return gotifyConfig, nil +} + +func prepareMatrixConfigInternal(config models.JSON, providerName string) (models.MatrixConfig, error) { + matrixConfig, err := decodeNotificationConfigInternal[models.MatrixConfig](config, providerName) + if err != nil { + return models.MatrixConfig{}, err + } + if err := decryptStringCredentialInternal(&matrixConfig.Password); err != nil { + return models.MatrixConfig{}, err + } + return matrixConfig, nil +} + +func buildVulnerabilitySummaryMessageInternal(format notifications.MessageFormat, environmentName string, payload VulnerabilityNotificationPayload) string { + return notifications.BuildVulnerabilitySummaryNotificationMessage( + format, + environmentName, + payload.CVEID, + payload.ImageName, + payload.FixedVersion, + payload.Severity, + payload.PkgName, + ) +} + func (s *NotificationService) decryptDiscordTokenInternal(config *models.DiscordConfig) error { if config.Token != "" { decrypted, err := crypto.Decrypt(config.Token) diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index 728b7b4209..d4eeed9bdb 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -1208,9 +1208,9 @@ func (s *ProjectService) enrichProjectUpdateInfoInternal(ctx context.Context, re return } - imageRefs := buildProjectImageRefsFromComposeConfigsInternal(resp.Services) + imageRefs := projects.ImageRefsFromComposeConfigs(resp.Services) if len(imageRefs) == 0 { - imageRefs = buildProjectImageRefsFromRuntimeServicesInternal(resp.RuntimeServices) + imageRefs = projects.ImageRefsFromRuntimeServices(resp.RuntimeServices) } var updateInfoByRef map[string]*imagetypes.UpdateInfo @@ -1329,54 +1329,7 @@ func (s *ProjectService) getProjectImageRefsFromComposeInternal(ctx context.Cont return nil, fmt.Errorf("load compose project: %w", err) } - return buildProjectImageRefsFromComposeServicesInternal(composeProject.Services), nil -} - -func buildProjectImageRefsFromComposeServicesInternal(services composetypes.Services) []string { - serviceConfigs := make([]composetypes.ServiceConfig, 0, len(services)) - for _, svc := range services { - serviceConfigs = append(serviceConfigs, svc) - } - - return buildProjectImageRefsFromComposeConfigsInternal(serviceConfigs) -} - -func buildProjectImageRefsFromComposeConfigsInternal(services []composetypes.ServiceConfig) []string { - refs := make([]string, 0, len(services)) - seen := make(map[string]struct{}, len(services)) - - for _, svc := range services { - ref := strings.TrimSpace(svc.Image) - if ref == "" { - continue - } - if _, exists := seen[ref]; exists { - continue - } - seen[ref] = struct{}{} - refs = append(refs, ref) - } - - return refs -} - -func buildProjectImageRefsFromRuntimeServicesInternal(services []project.RuntimeService) []string { - refs := make([]string, 0, len(services)) - seen := make(map[string]struct{}, len(services)) - - for _, svc := range services { - ref := strings.TrimSpace(svc.Image) - if ref == "" { - continue - } - if _, exists := seen[ref]; exists { - continue - } - seen[ref] = struct{}{} - refs = append(refs, ref) - } - - return refs + return projects.ImageRefsFromComposeServices(composeProject.Services), nil } func buildProjectUpdateInfoSummaryInternal( @@ -1412,13 +1365,11 @@ func buildProjectUpdateInfoSummaryInternal( if strings.TrimSpace(info.Error) != "" { summary.ErrorCount++ if summary.ErrorMessage == nil { - errMsg := strings.TrimSpace(info.Error) - summary.ErrorMessage = &errMsg + summary.ErrorMessage = new(strings.TrimSpace(info.Error)) } } if !info.CheckTime.IsZero() && (latestCheckTime == nil || info.CheckTime.After(*latestCheckTime)) { - checkTime := info.CheckTime - latestCheckTime = &checkTime + latestCheckTime = new(info.CheckTime) } } @@ -1753,7 +1704,7 @@ func (s *ProjectService) GetProjectStatusCounts(ctx context.Context) (folderCoun // 2. Group by project containersByProject := make(map[string][]container.Summary) for _, c := range containers { - projName := c.Labels["com.docker.compose.project"] + projName := dockerutil.ComposeProjectLabel(c.Labels) if projName != "" { containersByProject[projName] = append(containersByProject[projName], c) } @@ -2136,7 +2087,7 @@ func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, pr containersByProject := make(map[string][]container.Summary) for _, c := range containers { - projectName := c.Labels["com.docker.compose.project"] + projectName := dockerutil.ComposeProjectLabel(c.Labels) if projectName != "" { containersByProject[projectName] = append(containersByProject[projectName], c) } @@ -2259,10 +2210,6 @@ func (s *ProjectService) ensureImagesPresent(ctx context.Context, pullPlan map[s return nil } -func (s *ProjectService) pullImageForService(ctx context.Context, imageRef string, progressWriter io.Writer, credentials []containerregistry.Credential) error { - return s.pullAndReconcileImageInternal(ctx, imageRef, progressWriter, systemUser, credentials) -} - func (s *ProjectService) prepareProjectImagesForDeploy( ctx context.Context, projectID string, @@ -2349,7 +2296,7 @@ func (s *ProjectService) ensureDeployServiceImageReady( return nil } - if err := s.pullImageForService(ctx, imageName, progressWriter, credentials); err == nil { + if err := s.pullAndReconcileImageInternal(ctx, imageName, progressWriter, systemUser, credentials); err == nil { return nil } else if svc.Build != nil && decision.FallbackBuildOnPullFail { slog.WarnContext(ctx, "image pull failed, falling back to build", "service", serviceName, "image", imageName, "error", err) @@ -3838,7 +3785,7 @@ func buildDiscoveredComposeProjectUpdateRowsInternal( ) []project.Details { containersByProject := make(map[string][]container.Summary) for _, c := range composeContainers { - projectName := strings.TrimSpace(c.Labels["com.docker.compose.project"]) + projectName := dockerutil.ComposeProjectLabel(c.Labels) if projectName == "" { continue } @@ -3862,7 +3809,7 @@ func buildDiscoveredComposeProjectUpdateRowsInternal( rows := make([]project.Details, 0, len(containersByProject)) for projectName, projectContainers := range containersByProject { runtimeServices := buildDiscoveredRuntimeServicesInternal(projectContainers) - imageRefs := buildProjectImageRefsFromRuntimeServicesInternal(runtimeServices) + imageRefs := projects.ImageRefsFromRuntimeServices(runtimeServices) updateInfo := buildProjectUpdateInfoSummaryInternal(imageRefs, updateInfoByRef) if updateInfo == nil || !updateInfo.HasUpdate { continue @@ -3975,7 +3922,7 @@ func buildDiscoveredRuntimeServicesInternal(containers []container.Summary) []pr continue } - serviceName := strings.TrimSpace(c.Labels["com.docker.compose.service"]) + serviceName := dockerutil.ComposeServiceLabel(c.Labels) if serviceName == "" { serviceName = c.ID } @@ -3985,10 +3932,7 @@ func buildDiscoveredRuntimeServicesInternal(containers []container.Summary) []pr } seenServices[key] = struct{}{} - containerName := "" - if len(c.Names) > 0 { - containerName = strings.TrimPrefix(c.Names[0], "/") - } + containerName := dockerutil.ContainerNameFromNames(c.Names) runtimeServices = append(runtimeServices, project.RuntimeService{ Name: serviceName, @@ -4177,7 +4121,7 @@ func (s *ProjectService) fetchProjectStatusConcurrently(ctx context.Context, pro // 2. Group containers by project name containersByProject := make(map[string][]container.Summary) for _, c := range containers { - projName := c.Labels["com.docker.compose.project"] + projName := dockerutil.ComposeProjectLabel(c.Labels) if projName != "" { containersByProject[projName] = append(containersByProject[projName], c) } @@ -4214,7 +4158,7 @@ func (s *ProjectService) mapProjectToDto(ctx context.Context, projectsDir string runningCount := 0 for _, c := range projectContainers { - svcName := c.Labels["com.docker.compose.service"] + svcName := dockerutil.ComposeServiceLabel(c.Labels) state := c.State // "running", "exited", etc. // Parse health from Status string if possible @@ -4229,10 +4173,7 @@ func (s *ProjectService) mapProjectToDto(ctx context.Context, projectsDir string health = new("starting") } - containerName := "" - if len(c.Names) > 0 { - containerName = strings.TrimPrefix(c.Names[0], "/") - } + containerName := dockerutil.ContainerNameFromNames(c.Names) redeployDisabled := libupdater.ShouldDisableArcaneServerRedeploy(c.Labels, c.ID, currentContainerID, currentContainerErr) if redeployDisabled { diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index 19f6593954..c0459c44f1 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -738,7 +738,7 @@ func TestProjectService_PullImageForService_UpdatesCurrentImageRecordAfterPull(t CheckTime: time.Now().UTC().Add(-time.Hour), }).Error) - require.NoError(t, svc.pullImageForService(ctx, imageRef, io.Discard, nil)) + require.NoError(t, svc.pullAndReconcileImageInternal(ctx, imageRef, io.Discard, systemUser, nil)) // sha256:old-worker may still be in use by another container — must not be cleared (fixes #2453). var oldRecord models.ImageUpdateRecord @@ -2561,8 +2561,6 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { activePath := createComposeProjectDir(t, projectsRoot, "active-demo") archivedPath := createComposeProjectDir(t, projectsRoot, "archived-demo") - archivedAt := time.Now().UTC() - require.NoError(t, db.Create(&models.Project{ BaseModel: models.BaseModel{ID: "project-active"}, Name: "active-demo", @@ -2577,7 +2575,7 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { Path: archivedPath, Status: models.ProjectStatusStopped, IsArchived: true, - ArchivedAt: &archivedAt, + ArchivedAt: new(time.Now().UTC()), }).Error) svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) diff --git a/backend/internal/services/role_service.go b/backend/internal/services/role_service.go index a3adf8d7bb..6ecbc3c169 100644 --- a/backend/internal/services/role_service.go +++ b/backend/internal/services/role_service.go @@ -65,11 +65,10 @@ func (s *RoleService) EnsureBuiltInRoles(ctx context.Context) error { return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { for id, spec := range builtIns { - desc := spec.desc role := models.Role{ BaseModel: models.BaseModel{ID: id}, Name: spec.name, - Description: &desc, + Description: new(spec.desc), Permissions: models.StringSlice(spec.perm), BuiltIn: true, } diff --git a/backend/internal/services/settings_service_test.go b/backend/internal/services/settings_service_test.go index cb80a4d162..13d81fc2ea 100644 --- a/backend/internal/services/settings_service_test.go +++ b/backend/internal/services/settings_service_test.go @@ -337,11 +337,9 @@ func TestSettingsService_UpdateSettings_PruneModesDoNotTriggerScheduledPruneCall callbackCalls++ } - imageMode := "all" - containerUntil := "24h" _, err = svc.UpdateSettings(ctx, settings.Update{ - PruneImageMode: &imageMode, - PruneContainerUntil: &containerUntil, + PruneImageMode: new("all"), + PruneContainerUntil: new("24h"), }) require.NoError(t, err) require.Equal(t, 0, callbackCalls) @@ -358,9 +356,8 @@ func TestSettingsService_UpdateSettings_ScheduledPruneScheduleTriggersCallback(t callbackCalls++ } - enabled := "true" _, err = svc.UpdateSettings(ctx, settings.Update{ - ScheduledPruneEnabled: &enabled, + ScheduledPruneEnabled: new("true"), }) require.NoError(t, err) require.Equal(t, 1, callbackCalls) diff --git a/backend/internal/services/swarm_service.go b/backend/internal/services/swarm_service.go index 777edf1720..2b148bb421 100644 --- a/backend/internal/services/swarm_service.go +++ b/backend/internal/services/swarm_service.go @@ -454,10 +454,10 @@ func (s *SwarmService) StreamServiceLogs(ctx context.Context, serviceID string, defer func() { _ = logs.Close() }() if follow { - return streamMultiplexedLogs(ctx, logs, logsChan) + return dockerutil.StreamMultiplexedLogs(ctx, logs, logsChan) } - return readAllLogs(logs, logsChan) + return dockerutil.ReadAllLogs(ctx, logs, logsChan) } func (s *SwarmService) ListNodesPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]swarmtypes.NodeSummary, pagination.Response, error) { diff --git a/backend/internal/services/system_service.go b/backend/internal/services/system_service.go index 94d0efb887..fbedb9d5bd 100644 --- a/backend/internal/services/system_service.go +++ b/backend/internal/services/system_service.go @@ -233,13 +233,11 @@ func (s *SystemService) startSystemPruneActivityInternal(ctx context.Context, en if s.activityService == nil { return "" } - resourceType := "system" - resourceName := "Docker resources" activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ EnvironmentID: environmentID, Type: models.ActivityTypeSystemPrune, - ResourceType: &resourceType, - ResourceName: &resourceName, + ResourceType: new("system"), + ResourceName: new("Docker resources"), Step: "Preparing prune", LatestMessage: "System prune started", Metadata: models.JSON{ @@ -286,8 +284,7 @@ func (s *SystemService) completeSystemPruneActivityInternal(ctx context.Context, } else { status = models.ActivityStatusFailed message = "System prune completed with errors" - joined := strings.Join(result.Errors, "; ") - errMessage = &joined + errMessage = new(strings.Join(result.Errors, "; ")) } } if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { @@ -335,30 +332,35 @@ func (s *SystemService) performBatchContainerAction(ctx context.Context, contain } func (s *SystemService) StartAllContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { - activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStart, "All containers", "Starting all containers") - containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) - if err != nil { - result := &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - ActivityID: utils.StringPtrFromTrimmed(activityID), - } - s.completeSystemContainerActivityInternal(ctx, activityID, "Starting all containers failed", result) - return result, err - } - - result := s.performBatchContainerAction(ctx, containers, "start", - func(c container.Summary) bool { return c.State != "running" }, - func(ctx context.Context, id string) error { - return s.containerService.StartContainer(ctx, id, systemUser) - }) - result.ActivityID = utils.StringPtrFromTrimmed(activityID) - s.completeSystemContainerActivityInternal(ctx, activityID, "Started all containers", result) - return result, nil + return s.startMatchingContainersInternal(ctx, environmentID, startMatchingContainersOptionsInternal{ + ResourceName: "All containers", + StartMessage: "Starting all containers", + FailureMessage: "Starting all containers failed", + SuccessMessage: "Started all containers", + ShouldStart: func(c container.Summary) bool { return c.State != "running" }, + }) } func (s *SystemService) StartAllStoppedContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { - activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStart, "Stopped containers", "Starting stopped containers") + return s.startMatchingContainersInternal(ctx, environmentID, startMatchingContainersOptionsInternal{ + ResourceName: "Stopped containers", + StartMessage: "Starting stopped containers", + FailureMessage: "Starting stopped containers failed", + SuccessMessage: "Started stopped containers", + ShouldStart: func(c container.Summary) bool { return c.State == "exited" }, + }) +} + +type startMatchingContainersOptionsInternal struct { + ResourceName string + StartMessage string + FailureMessage string + SuccessMessage string + ShouldStart func(container.Summary) bool +} + +func (s *SystemService) startMatchingContainersInternal(ctx context.Context, environmentID string, opts startMatchingContainersOptionsInternal) (*containertypes.ActionResult, error) { + activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStart, opts.ResourceName, opts.StartMessage) containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) if err != nil { result := &containertypes.ActionResult{ @@ -366,17 +368,15 @@ func (s *SystemService) StartAllStoppedContainers(ctx context.Context, environme Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, ActivityID: utils.StringPtrFromTrimmed(activityID), } - s.completeSystemContainerActivityInternal(ctx, activityID, "Starting stopped containers failed", result) + s.completeSystemContainerActivityInternal(ctx, activityID, opts.FailureMessage, result) return result, err } - result := s.performBatchContainerAction(ctx, containers, "start", - func(c container.Summary) bool { return c.State == "exited" }, - func(ctx context.Context, id string) error { - return s.containerService.StartContainer(ctx, id, systemUser) - }) + result := s.performBatchContainerAction(ctx, containers, "start", opts.ShouldStart, func(ctx context.Context, id string) error { + return s.containerService.StartContainer(ctx, id, systemUser) + }) result.ActivityID = utils.StringPtrFromTrimmed(activityID) - s.completeSystemContainerActivityInternal(ctx, activityID, "Started stopped containers", result) + s.completeSystemContainerActivityInternal(ctx, activityID, opts.SuccessMessage, result) return result, nil } @@ -410,11 +410,10 @@ func (s *SystemService) startSystemContainerActivityInternal(ctx context.Context if s.activityService == nil { return "" } - resourceType := "system" activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ EnvironmentID: environmentID, Type: activityType, - ResourceType: &resourceType, + ResourceType: new("system"), ResourceName: &resourceName, Step: message, LatestMessage: message, diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index 677fc8407f..3940f35199 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -21,6 +21,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" @@ -413,13 +414,11 @@ func (s *UpdaterService) startAutoUpdateActivityInternal(ctx context.Context, dr if s.activityService == nil { return "" } - resourceType := "system" - resourceName := "Auto update" activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ EnvironmentID: "0", Type: models.ActivityTypeAutoUpdate, - ResourceType: &resourceType, - ResourceName: &resourceName, + ResourceType: new("system"), + ResourceName: new("Auto update"), Step: "Planning updates", LatestMessage: "Auto-update run started", Metadata: models.JSON{"dryRun": dryRun}, @@ -435,14 +434,12 @@ func (s *UpdaterService) startSingleContainerUpdateActivityInternal(ctx context. if s.activityService == nil { return "" } - resourceType := "container" - resourceName := containerID activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ EnvironmentID: "0", Type: models.ActivityTypeAutoUpdate, - ResourceType: &resourceType, + ResourceType: new("container"), ResourceID: &containerID, - ResourceName: &resourceName, + ResourceName: new(containerID), Step: "Updating container", LatestMessage: "Container update started", Metadata: models.JSON{"containerID": containerID}, @@ -541,7 +538,7 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID return nil, fmt.Errorf("container not found: %s", containerID) } - containerName := s.getContainerName(*targetContainer) + containerName := dockerutil.ContainerSummaryName(*targetContainer) slog.InfoContext(ctx, "UpdateSingleContainer: found container", "containerID", containerID, "name", containerName, "image", targetContainer.Image) // Inspect container to get full config (needed for label-based controls) @@ -716,8 +713,8 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID } // Update the container - projectName := inspect.Config.Labels["com.docker.compose.project"] - serviceName := inspect.Config.Labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(inspect.Config.Labels) + serviceName := dockerutil.ComposeServiceLabel(inspect.Config.Labels) if projectName != "" && serviceName != "" { slog.InfoContext(ctx, "UpdateSingleContainer: detected compose container, using project-based update", "containerID", containerID, "project", projectName, "service", serviceName) @@ -886,7 +883,7 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ return fmt.Errorf("docker connect: %w", err) } - name := s.getContainerName(cnt) + name := dockerutil.ContainerSummaryName(cnt) labels := inspect.Config.Labels isArcane := libupdater.IsArcaneContainer(labels) @@ -1251,7 +1248,7 @@ func activeComposeProjectNameSetInternal(projects []models.Project) map[string]s func (s *UpdaterService) collectUsedImagesFromComposeContainersInternal(ctx context.Context, composeContainers []container.Summary, activeProjectNames map[string]struct{}, out map[string]struct{}) { for _, c := range composeContainers { - projectName := strings.TrimSpace(c.Labels["com.docker.compose.project"]) + projectName := dockerutil.ComposeProjectLabel(c.Labels) if projectName == "" { continue } @@ -1300,17 +1297,6 @@ func (s *UpdaterService) getNormalizedTagsForContainer(ctx context.Context, dcli return out } -func (s *UpdaterService) getContainerName(cnt container.Summary) string { - if len(cnt.Names) > 0 { - n := cnt.Names[0] - if strings.HasPrefix(n, "/") { - return n[1:] - } - return n - } - return cnt.ID[:12] -} - func (s *UpdaterService) recordRun(ctx context.Context, item updater.ResourceResult) error { rec := &models.AutoUpdateRecord{ ResourceID: item.ResourceID, @@ -1456,7 +1442,7 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID c.Labels = map[string]string{} } - name := s.getContainerName(c) + name := dockerutil.ContainerSummaryName(c) containersWithDeps = append(containersWithDeps, libupdater.ContainerWithDeps{ Container: c, Name: name, @@ -1555,8 +1541,8 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID if p == nil || p.newRef == "" || p.inspect == nil || p.inspect.Config == nil { continue } - projectName := p.inspect.Config.Labels["com.docker.compose.project"] - serviceName := p.inspect.Config.Labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(p.inspect.Config.Labels) + serviceName := dockerutil.ComposeServiceLabel(p.inspect.Config.Labels) if projectName != "" && serviceName != "" { projectID, ok := lookupComposeProjectIDInternal(projectName, projectNameToID) if !ok { @@ -1640,8 +1626,8 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID endProjectStatus := s.beginProjectUpdateInternal(composeProjectNameFromLabelsInternal(labels)) defer endProjectStatus() - projectName := labels["com.docker.compose.project"] - serviceName := labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(labels) + serviceName := dockerutil.ComposeServiceLabel(labels) var proj *models.Project if projectName != "" { if projectID, ok := lookupComposeProjectIDInternal(projectName, projectNameToID); ok { @@ -1801,8 +1787,8 @@ func (s *UpdaterService) lazyRegisterComposeProjectInternal( if p.newRef == "" || p.inspect == nil || p.inspect.Config == nil { return } - projectName := p.inspect.Config.Labels["com.docker.compose.project"] - serviceName := p.inspect.Config.Labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(p.inspect.Config.Labels) + serviceName := dockerutil.ComposeServiceLabel(p.inspect.Config.Labels) if projectName == "" || serviceName == "" { return } @@ -1865,7 +1851,7 @@ func composeProjectNameFromLabelsInternal(labels map[string]string) string { if len(labels) == 0 { return "" } - return strings.TrimSpace(labels["com.docker.compose.project"]) + return dockerutil.ComposeProjectLabel(labels) } func lookupComposeProjectIDInternal(projectName string, projectNameToID map[string]string) (string, bool) { diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index f445dae2ba..1e0331a87a 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -448,16 +448,14 @@ func (s *VulnerabilityService) startVulnerabilityScanActivityInternal(ctx contex return "" } - resourceType := "image" - progress := 0 activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ EnvironmentID: envID, Type: models.ActivityTypeVulnerabilityScan, - ResourceType: &resourceType, + ResourceType: new("image"), ResourceID: &imageID, ResourceName: &imageName, StartedBy: user, - Progress: &progress, + Progress: new(0), Step: string(vulnerability.ScanPhaseCreatingContainer), LatestMessage: "Vulnerability scan queued", }) diff --git a/backend/pkg/dockerutil/compose_labels.go b/backend/pkg/dockerutil/compose_labels.go new file mode 100644 index 0000000000..92b1c07960 --- /dev/null +++ b/backend/pkg/dockerutil/compose_labels.go @@ -0,0 +1,22 @@ +package docker + +import "strings" + +// Docker Compose attaches these labels to every container it manages. They +// identify the owning project and the service within that project. +const ( + ComposeProjectLabelKey = "com.docker.compose.project" + ComposeServiceLabelKey = "com.docker.compose.service" +) + +// ComposeProjectLabel returns the trimmed Docker Compose project name from a +// container's labels, or "" when unset. +func ComposeProjectLabel(labels map[string]string) string { + return strings.TrimSpace(labels[ComposeProjectLabelKey]) +} + +// ComposeServiceLabel returns the trimmed Docker Compose service name from a +// container's labels, or "" when unset. +func ComposeServiceLabel(labels map[string]string) string { + return strings.TrimSpace(labels[ComposeServiceLabelKey]) +} diff --git a/backend/pkg/dockerutil/compose_labels_test.go b/backend/pkg/dockerutil/compose_labels_test.go new file mode 100644 index 0000000000..f96d42539b --- /dev/null +++ b/backend/pkg/dockerutil/compose_labels_test.go @@ -0,0 +1,24 @@ +package docker + +import "testing" + +func TestComposeLabels(t *testing.T) { + labels := map[string]string{ + ComposeProjectLabelKey: " myproj ", + ComposeServiceLabelKey: "web", + } + + if got := ComposeProjectLabel(labels); got != "myproj" { + t.Errorf("ComposeProjectLabel() = %q, want %q", got, "myproj") + } + if got := ComposeServiceLabel(labels); got != "web" { + t.Errorf("ComposeServiceLabel() = %q, want %q", got, "web") + } + + if got := ComposeProjectLabel(nil); got != "" { + t.Errorf("ComposeProjectLabel(nil) = %q, want %q", got, "") + } + if got := ComposeServiceLabel(map[string]string{}); got != "" { + t.Errorf("ComposeServiceLabel(empty) = %q, want %q", got, "") + } +} diff --git a/backend/pkg/dockerutil/container_names.go b/backend/pkg/dockerutil/container_names.go new file mode 100644 index 0000000000..464b59d3d1 --- /dev/null +++ b/backend/pkg/dockerutil/container_names.go @@ -0,0 +1,30 @@ +package docker + +import ( + "strings" + + "github.com/moby/moby/api/types/container" +) + +// ContainerNameFromNames returns Docker's first container name without the +// leading slash Docker stores in container summaries. +func ContainerNameFromNames(names []string) string { + if len(names) == 0 { + return "" + } + + return strings.TrimPrefix(names[0], "/") +} + +// ContainerSummaryName returns a displayable container name from a Docker +// summary, falling back to the short container ID when Docker has no name. +func ContainerSummaryName(cnt container.Summary) string { + if name := ContainerNameFromNames(cnt.Names); name != "" { + return name + } + + if len(cnt.ID) >= 12 { + return cnt.ID[:12] + } + return cnt.ID +} diff --git a/backend/pkg/dockerutil/container_names_test.go b/backend/pkg/dockerutil/container_names_test.go new file mode 100644 index 0000000000..8e1ddd466f --- /dev/null +++ b/backend/pkg/dockerutil/container_names_test.go @@ -0,0 +1,71 @@ +package docker + +import ( + "testing" + + "github.com/moby/moby/api/types/container" +) + +func TestContainerNameFromNames(t *testing.T) { + tests := []struct { + name string + names []string + want string + }{ + { + name: "single name with slash", + names: []string{"/myapp"}, + want: "myapp", + }, + { + name: "single name without slash", + names: []string{"myapp"}, + want: "myapp", + }, + { + name: "multiple names uses first", + names: []string{"/myapp", "/myapp-alias"}, + want: "myapp", + }, + { + name: "no names", + names: []string{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainerNameFromNames(tt.names); got != tt.want { + t.Errorf("ContainerNameFromNames() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContainerSummaryName(t *testing.T) { + tests := []struct { + name string + cnt container.Summary + want string + }{ + { + name: "uses Docker name", + cnt: container.Summary{Names: []string{"/myapp"}, ID: "abc123456789"}, + want: "myapp", + }, + { + name: "falls back to short ID", + cnt: container.Summary{Names: []string{}, ID: "abc123456789"}, + want: "abc123456789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainerSummaryName(tt.cnt); got != tt.want { + t.Errorf("ContainerSummaryName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/pkg/dockerutil/log_stream.go b/backend/pkg/dockerutil/log_stream.go new file mode 100644 index 0000000000..2d74e6b06c --- /dev/null +++ b/backend/pkg/dockerutil/log_stream.go @@ -0,0 +1,170 @@ +package docker + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "sync" + + "github.com/moby/moby/api/pkg/stdcopy" +) + +// StreamContainerLogs streams Docker container logs, handling TTY raw streams +// and non-TTY multiplexed stdout/stderr streams. +func StreamContainerLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string, follow bool, isTTY bool) error { + if isTTY { + return readLogLinesInternal(ctx, logs, logsChan, "") + } + if follow { + return StreamMultiplexedLogs(ctx, logs, logsChan) + } + return ReadAllLogs(ctx, logs, logsChan) +} + +// StreamMultiplexedLogs demultiplexes a Docker stdout/stderr log stream and +// sends non-empty lines to logsChan. +func StreamMultiplexedLogs(ctx context.Context, logs io.Reader, logsChan chan<- string) error { + stdoutReader, stdoutWriter := io.Pipe() + stderrReader, stderrWriter := io.Pipe() + + var closeOnce sync.Once + closeInputs := func() { + closeOnce.Do(func() { + err := ctx.Err() + if err == nil { + err = io.ErrClosedPipe + } + _ = stdoutReader.CloseWithError(err) + _ = stderrReader.CloseWithError(err) + if closer, ok := logs.(io.Closer); ok { + _ = closer.Close() + } + }) + } + + copyDone := make(chan struct{}) + go func() { + defer close(copyDone) + defer func() { _ = stdoutWriter.Close() }() + defer func() { _ = stderrWriter.Close() }() + _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, logs) + if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil { + slog.Error("error demultiplexing logs", "error", err) + } + }() + + go func() { + select { + case <-ctx.Done(): + closeInputs() + case <-copyDone: + } + }() + + done := make(chan error, 2) + + go func() { + done <- readLogLinesInternal(ctx, stdoutReader, logsChan, "") + }() + + go func() { + done <- readLogLinesInternal(ctx, stderrReader, logsChan, "[STDERR] ") + }() + + select { + case <-ctx.Done(): + closeInputs() + return ctx.Err() + case err := <-done: + if err != nil && !errors.Is(err, io.EOF) { + closeInputs() + return err + } + select { + case <-ctx.Done(): + closeInputs() + return ctx.Err() + case <-done: + return nil + } + } +} + +// ReadAllLogs reads a non-follow Docker multiplexed log stream and sends +// non-empty stdout/stderr lines to logsChan. +func ReadAllLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string) error { + stdoutBuf := &strings.Builder{} + stderrBuf := &strings.Builder{} + stdCopyDone := make(chan struct{}) + defer close(stdCopyDone) + + go func() { + select { + case <-ctx.Done(): + _ = logs.Close() + case <-stdCopyDone: + } + }() + + _, err := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) + if err != nil && !errors.Is(err, io.EOF) { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("failed to demultiplex logs: %w", err) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + + if stdoutBuf.Len() > 0 { + if err := readLogLinesInternal(ctx, strings.NewReader(stdoutBuf.String()), logsChan, ""); err != nil { + return err + } + } + + if stderrBuf.Len() > 0 { + if err := readLogLinesInternal(ctx, strings.NewReader(stderrBuf.String()), logsChan, "[STDERR] "); err != nil { + return err + } + } + + return nil +} + +func readLogLinesInternal(ctx context.Context, reader io.Reader, logsChan chan<- string, prefix string) error { + bufferedReader := bufio.NewReader(reader) + + for { + if err := ctx.Err(); err != nil { + return err + } + + line, err := bufferedReader.ReadString('\n') + if len(line) > 0 { + trimmed := strings.TrimRight(line, "\r\n") + if trimmed != "" { + if prefix != "" { + trimmed = prefix + trimmed + } + + select { + case logsChan <- trimmed: + case <-ctx.Done(): + return ctx.Err() + } + } + } + + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + } +} diff --git a/backend/pkg/dockerutil/log_stream_test.go b/backend/pkg/dockerutil/log_stream_test.go new file mode 100644 index 0000000000..bd373c2294 --- /dev/null +++ b/backend/pkg/dockerutil/log_stream_test.go @@ -0,0 +1,198 @@ +package docker + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStreamContainerLogsNonTTYFollowDemultiplexesStdoutAndStderr(t *testing.T) { + var stream bytes.Buffer + writeDockerLogFrameInternal(t, &stream, 1, "stdout line\n") + writeDockerLogFrameInternal(t, &stream, 2, "stderr line\n") + + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, true, false) + require.NoError(t, err) + + require.ElementsMatch(t, []string{"stdout line", "[STDERR] stderr line"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYFollowStreamsRawOutput(t *testing.T) { + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(strings.NewReader("first line\nsecond line")), logsChan, true, true) + require.NoError(t, err) + + require.Equal(t, []string{"first line", "second line"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsNonTTYSnapshotDemultiplexesStdoutAndStderr(t *testing.T) { + var stream bytes.Buffer + writeDockerLogFrameInternal(t, &stream, 1, "stdout snapshot\n") + writeDockerLogFrameInternal(t, &stream, 2, "stderr snapshot\n") + + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, false, false) + require.NoError(t, err) + + require.Equal(t, []string{"stdout snapshot", "[STDERR] stderr snapshot"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYSnapshotStreamsRawOutput(t *testing.T) { + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(strings.NewReader("snapshot line\ntrailing line")), logsChan, false, true) + require.NoError(t, err) + + require.Equal(t, []string{"snapshot line", "trailing line"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYHandlesLongLinesAndPartialEOF(t *testing.T) { + longLine := strings.Repeat("a", 70*1024) + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(strings.NewReader(longLine+"\npartial tail")), logsChan, true, true) + require.NoError(t, err) + + require.Equal(t, []string{longLine, "partial tail"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYPythonLikeFollowDoesNotReturnEmptyLogs(t *testing.T) { + logsChan := make(chan string, 4) + + err := StreamContainerLogs( + t.Context(), + io.NopCloser(strings.NewReader("2026-03-22 10:15:00 - INFO - Starting miner\n2026-03-22 10:15:01 - INFO - Connected")), + logsChan, + true, + true, + ) + require.NoError(t, err) + + lines := drainLogLinesInternal(logsChan) + require.NotEmpty(t, lines) + require.Equal(t, []string{ + "2026-03-22 10:15:00 - INFO - Starting miner", + "2026-03-22 10:15:01 - INFO - Connected", + }, lines) +} + +func TestStreamMultiplexedLogsContextCancelDoesNotDeadlock(t *testing.T) { + var stream bytes.Buffer + writeDockerLogFrameInternal(t, &stream, 1, "line 1\n") + writeDockerLogFrameInternal(t, &stream, 1, "line 2\n") + writeDockerLogFrameInternal(t, &stream, 1, "line 3\n") + + logsChan := make(chan string, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- StreamMultiplexedLogs(ctx, bytes.NewReader(stream.Bytes()), logsChan) + }() + + require.Eventually(t, func() bool { + return len(logsChan) == 1 + }, time.Second, 10*time.Millisecond) + + cancel() + + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("StreamMultiplexedLogs did not exit after cancellation") + } +} + +func TestReadAllLogsContextCancelClosesReader(t *testing.T) { + logsChan := make(chan string, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + reader := &blockingReadCloserInternal{readStarted: make(chan struct{}), closeCalled: make(chan struct{})} + done := make(chan error, 1) + go func() { + done <- ReadAllLogs(ctx, reader, logsChan) + }() + + select { + case <-reader.readStarted: + case <-time.After(time.Second): + t.Fatal("ReadAllLogs did not start reading") + } + + cancel() + + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("ReadAllLogs did not exit after cancellation") + } + + select { + case <-reader.closeCalled: + case <-time.After(time.Second): + t.Fatal("ReadAllLogs did not close the reader on cancellation") + } +} + +func drainLogLinesInternal(logsChan chan string) []string { + close(logsChan) + + lines := make([]string, 0, len(logsChan)) + for line := range logsChan { + lines = append(lines, line) + } + + return lines +} + +func writeDockerLogFrameInternal(t *testing.T, buffer *bytes.Buffer, streamType byte, payload string) { + t.Helper() + + header := make([]byte, 8) + header[0] = streamType + binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) + + _, err := buffer.Write(header) + require.NoError(t, err) + _, err = buffer.WriteString(payload) + require.NoError(t, err) +} + +type blockingReadCloserInternal struct { + readStarted chan struct{} + closeCalled chan struct{} +} + +func (r *blockingReadCloserInternal) Read(_ []byte) (int, error) { + select { + case <-r.readStarted: + default: + close(r.readStarted) + } + + <-r.closeCalled + return 0, io.EOF +} + +func (r *blockingReadCloserInternal) Close() error { + select { + case <-r.closeCalled: + default: + close(r.closeCalled) + } + return nil +} diff --git a/backend/pkg/libarcane/activity/writer.go b/backend/pkg/libarcane/activity/writer.go index e56e5af34d..2577d645bc 100644 --- a/backend/pkg/libarcane/activity/writer.go +++ b/backend/pkg/libarcane/activity/writer.go @@ -373,11 +373,9 @@ func phaseMessageInternal(typ, phase string, payload map[string]any) string { func phaseProgressInternal(phase string, rawDetail any) *int { switch phase { case "begin": - out := 5 - return &out + return new(5) case "complete": - out := 100 - return &out + return new(100) default: return progressDetailPercentInternal(rawDetail) } @@ -407,8 +405,7 @@ func progressDetailPercentInternal(rawDetail any) *int { progress = 100 } - out := int(progress) - return &out + return new(int(progress)) } func containerEventMessageInternal(payload map[string]any) string { diff --git a/backend/pkg/libarcane/edge/client.go b/backend/pkg/libarcane/edge/client.go index e3f6303a0a..f4d5afbe8b 100644 --- a/backend/pkg/libarcane/edge/client.go +++ b/backend/pkg/libarcane/edge/client.go @@ -232,30 +232,51 @@ func (c *TunnelClient) connectAndServeManagedTunnelInternal(ctx context.Context) } func (c *TunnelClient) shouldFallbackToWebSocketInternal() bool { - if !c.shouldAttemptWebSocketTunnelInternal() { - return false - } - return c.managerWebSocketURLInternal() != "" + return c.shouldAttemptWebSocketTunnelInternal() } func (c *TunnelClient) shouldAttemptGRPCTunnelInternal() bool { - if c == nil { - return false - } - if UsePollEdgeTransport(c.cfg) { - return strings.TrimSpace(c.managerGRPCAddr) != "" - } - return UseGRPCEdgeTransport(c.cfg) + transports := c.managedTunnelTransportsInternal() + return transports.grpc } func (c *TunnelClient) shouldAttemptWebSocketTunnelInternal() bool { - if c == nil { - return false + transports := c.managedTunnelTransportsInternal() + return transports.websocket +} + +type managedTunnelTransportsInternal struct { + grpc bool + websocket bool +} + +func (c *TunnelClient) managedTunnelTransportsInternal() managedTunnelTransportsInternal { + if c == nil || c.cfg == nil { + return managedTunnelTransportsInternal{} } - if UsePollEdgeTransport(c.cfg) { - return c.managerWebSocketURLInternal() != "" + + managerGRPCAvailable := strings.TrimSpace(c.managerGRPCAddr) != "" + managerWebSocketAvailable := c.managerWebSocketURLInternal() != "" + transport := NormalizeEdgeTransport(c.cfg.EdgeTransport) + + switch transport { + case EdgeTransportPoll: + return managedTunnelTransportsInternal{ + grpc: managerGRPCAvailable, + websocket: managerWebSocketAvailable, + } + case EdgeTransportWebSocket: + return managedTunnelTransportsInternal{ + websocket: managerWebSocketAvailable, + } + case EdgeTransportGRPC: + return managedTunnelTransportsInternal{ + grpc: managerGRPCAvailable, + websocket: strings.TrimSpace(c.cfg.EdgeTransport) == "" && managerWebSocketAvailable, + } + default: + return managedTunnelTransportsInternal{} } - return UseWebSocketEdgeTransport(c.cfg) } func (c *TunnelClient) managerWebSocketURLInternal() string { diff --git a/backend/pkg/libarcane/edge/client_grpc_test.go b/backend/pkg/libarcane/edge/client_grpc_test.go index 68a5464411..b9b26d2647 100644 --- a/backend/pkg/libarcane/edge/client_grpc_test.go +++ b/backend/pkg/libarcane/edge/client_grpc_test.go @@ -575,7 +575,7 @@ func TestTunnelClient_connectAndServe_WebSocketConfigFallsBackToWebSocket(t *tes } func TestTunnelClient_connectAndServe_OpensGRPCWhenAvailable(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() envID := "env-auto-poll-grpc" @@ -616,8 +616,14 @@ func TestTunnelClient_connectAndServe_OpensGRPCWhenAvailable(t *testing.T) { return isGRPC }, 3*time.Second, 20*time.Millisecond) - err := <-errCh - assert.ErrorIs(t, err, context.DeadlineExceeded) + cancel() + + select { + case err := <-errCh: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for gRPC tunnel shutdown") + } } func startTestGRPCTunnelServerOnAPIPathInternal(t *testing.T, ctx context.Context, tunnelServer *TunnelServer) (string, func()) { diff --git a/backend/pkg/libarcane/imageupdate/registry_http.go b/backend/pkg/libarcane/imageupdate/registry_http.go index bab7b78997..5fdddeb562 100644 --- a/backend/pkg/libarcane/imageupdate/registry_http.go +++ b/backend/pkg/libarcane/imageupdate/registry_http.go @@ -445,8 +445,7 @@ func extractRateLimitFromHeadersInternal(headers http.Header) (*RateLimitInfo, e var used *int if limit != nil && remaining != nil { - value := *limit - *remaining - used = &value + used = new(*limit - *remaining) } return &RateLimitInfo{ diff --git a/backend/pkg/libarcane/imageupdate/sorter.go b/backend/pkg/libarcane/imageupdate/sorter.go index b8020b9a14..187a4a72ad 100644 --- a/backend/pkg/libarcane/imageupdate/sorter.go +++ b/backend/pkg/libarcane/imageupdate/sorter.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" ) @@ -127,7 +128,7 @@ func ExtractContainerDeps(ctx context.Context, dcli *client.Client, cnt containe c := ContainerWithDeps{ Container: cnt, Inspect: inspect, - Name: ExtractContainerName(cnt), + Name: dockerutil.ContainerSummaryName(cnt), } // Extract Docker links @@ -212,15 +213,3 @@ func hasMarkedDependencyInternal(markedForRestart map[string]bool, deps []string return false } - -// ExtractContainerName extracts a clean container name from the summary -func ExtractContainerName(cnt container.Summary) string { - if len(cnt.Names) > 0 { - n := cnt.Names[0] - if strings.HasPrefix(n, "/") { - return n[1:] - } - return n - } - return cnt.ID[:12] -} diff --git a/backend/pkg/libarcane/imageupdate/sorter_test.go b/backend/pkg/libarcane/imageupdate/sorter_test.go index deaf83db93..2c4b85b6c8 100644 --- a/backend/pkg/libarcane/imageupdate/sorter_test.go +++ b/backend/pkg/libarcane/imageupdate/sorter_test.go @@ -351,40 +351,3 @@ func TestUpdateImplicitRestart(t *testing.T) { }) } } - -func TestExtractContainerName(t *testing.T) { - tests := []struct { - name string - cnt container.Summary - want string - }{ - { - name: "single name with slash", - cnt: container.Summary{Names: []string{"/myapp"}, ID: "abc123"}, - want: "myapp", - }, - { - name: "single name without slash", - cnt: container.Summary{Names: []string{"myapp"}, ID: "abc123"}, - want: "myapp", - }, - { - name: "multiple names - uses first", - cnt: container.Summary{Names: []string{"/myapp", "/myapp-alias"}, ID: "abc123"}, - want: "myapp", - }, - { - name: "no names - falls back to short ID", - cnt: container.Summary{Names: []string{}, ID: "abc123456789"}, - want: "abc123456789", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ExtractContainerName(tt.cnt); got != tt.want { - t.Errorf("ExtractContainerName() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/backend/pkg/libarcane/swarm/stack_deploy_engine.go b/backend/pkg/libarcane/swarm/stack_deploy_engine.go index ad8dfc588f..2fd276ae8d 100644 --- a/backend/pkg/libarcane/swarm/stack_deploy_engine.go +++ b/backend/pkg/libarcane/swarm/stack_deploy_engine.go @@ -485,69 +485,95 @@ func ensureSwarmSecrets(ctx context.Context, dockerClient *dockerclient.Client, } func ensureConfig(ctx context.Context, dockerClient *dockerclient.Client, name string, cfg composegotypes.ConfigObjConfig, stackLabels map[string]string, workingDir string) (resourceMeta, error) { - data, err := resolveFileObjectContent(composegotypes.FileObjectConfig(cfg), workingDir) - if err != nil { - return resourceMeta{}, fmt.Errorf("failed to load config %s: %w", name, err) - } + return ensureManagedFileResourceInternal(ctx, name, composegotypes.FileObjectConfig(cfg), cfg.Labels, stackLabels, workingDir, managedFileResourceOptionsInternal{ + ResourceType: "config", + Inspect: func(ctx context.Context, managedName string) (resourceMeta, error) { + return inspectConfig(ctx, dockerClient, managedName) + }, + Create: createConfigResourceInternal(dockerClient), + }) +} - hash := hashManagedResource(data) - managedName := managedResourceName(name, hash) - if meta, err := inspectConfig(ctx, dockerClient, managedName); err == nil { - return meta, nil - } else if !cerrdefs.IsNotFound(err) { - return resourceMeta{}, fmt.Errorf("failed to inspect config %s: %w", managedName, err) +func ensureSecret(ctx context.Context, dockerClient *dockerclient.Client, name string, cfg composegotypes.SecretConfig, stackLabels map[string]string, workingDir string) (resourceMeta, error) { + return ensureManagedFileResourceInternal(ctx, name, composegotypes.FileObjectConfig(cfg), cfg.Labels, stackLabels, workingDir, managedFileResourceOptionsInternal{ + ResourceType: "secret", + Inspect: func(ctx context.Context, managedName string) (resourceMeta, error) { + return inspectSecret(ctx, dockerClient, managedName) + }, + Create: createSecretResourceInternal(dockerClient), + }) +} + +func createConfigResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string, map[string]string, []byte) (resourceMeta, error) { + return func(ctx context.Context, managedName string, labels map[string]string, data []byte) (resourceMeta, error) { + spec := swarm.ConfigSpec{ + Annotations: managedResourceAnnotationsInternal(managedName, labels), + Data: data, + } + + resp, err := dockerClient.ConfigCreate(ctx, dockerclient.ConfigCreateOptions{Spec: spec}) + if err != nil { + return resourceMeta{}, fmt.Errorf("failed to create config %s: %w", managedName, err) + } + return resourceMeta{ID: resp.ID, Name: managedName}, nil } +} - labels := mergeLabels(cfg.Labels, stackLabels) - labels[resourceTypeLabel] = "config" - labels[resourceNameLabel] = name - labels[resourceHashLabel] = hash - spec := swarm.ConfigSpec{ - Annotations: swarm.Annotations{ - Name: managedName, - Labels: labels, - }, - Data: data, +func createSecretResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string, map[string]string, []byte) (resourceMeta, error) { + return func(ctx context.Context, managedName string, labels map[string]string, data []byte) (resourceMeta, error) { + spec := swarm.SecretSpec{ + Annotations: managedResourceAnnotationsInternal(managedName, labels), + Data: data, + } + + resp, err := dockerClient.SecretCreate(ctx, dockerclient.SecretCreateOptions{Spec: spec}) + if err != nil { + return resourceMeta{}, fmt.Errorf("failed to create secret %s: %w", managedName, err) + } + return resourceMeta{ID: resp.ID, Name: managedName}, nil } +} - resp, err := dockerClient.ConfigCreate(ctx, dockerclient.ConfigCreateOptions{Spec: spec}) - if err != nil { - return resourceMeta{}, fmt.Errorf("failed to create config %s: %w", managedName, err) +func managedResourceAnnotationsInternal(name string, labels map[string]string) swarm.Annotations { + return swarm.Annotations{ + Name: name, + Labels: labels, } - return resourceMeta{ID: resp.ID, Name: managedName}, nil } -func ensureSecret(ctx context.Context, dockerClient *dockerclient.Client, name string, cfg composegotypes.SecretConfig, stackLabels map[string]string, workingDir string) (resourceMeta, error) { - data, err := resolveFileObjectContent(composegotypes.FileObjectConfig(cfg), workingDir) +type managedFileResourceOptionsInternal struct { + ResourceType string + Inspect func(context.Context, string) (resourceMeta, error) + Create func(context.Context, string, map[string]string, []byte) (resourceMeta, error) +} + +func ensureManagedFileResourceInternal( + ctx context.Context, + name string, + fileObject composegotypes.FileObjectConfig, + labels map[string]string, + stackLabels map[string]string, + workingDir string, + opts managedFileResourceOptionsInternal, +) (resourceMeta, error) { + data, err := resolveFileObjectContent(fileObject, workingDir) if err != nil { - return resourceMeta{}, fmt.Errorf("failed to load secret %s: %w", name, err) + return resourceMeta{}, fmt.Errorf("failed to load %s %s: %w", opts.ResourceType, name, err) } hash := hashManagedResource(data) managedName := managedResourceName(name, hash) - if meta, err := inspectSecret(ctx, dockerClient, managedName); err == nil { + if meta, err := opts.Inspect(ctx, managedName); err == nil { return meta, nil } else if !cerrdefs.IsNotFound(err) { - return resourceMeta{}, fmt.Errorf("failed to inspect secret %s: %w", managedName, err) + return resourceMeta{}, fmt.Errorf("failed to inspect %s %s: %w", opts.ResourceType, managedName, err) } - labels := mergeLabels(cfg.Labels, stackLabels) - labels[resourceTypeLabel] = "secret" - labels[resourceNameLabel] = name - labels[resourceHashLabel] = hash - spec := swarm.SecretSpec{ - Annotations: swarm.Annotations{ - Name: managedName, - Labels: labels, - }, - Data: data, - } - - resp, err := dockerClient.SecretCreate(ctx, dockerclient.SecretCreateOptions{Spec: spec}) - if err != nil { - return resourceMeta{}, fmt.Errorf("failed to create secret %s: %w", managedName, err) - } - return resourceMeta{ID: resp.ID, Name: managedName}, nil + resourceLabels := mergeLabels(labels, stackLabels) + resourceLabels[resourceTypeLabel] = opts.ResourceType + resourceLabels[resourceNameLabel] = name + resourceLabels[resourceHashLabel] = hash + return opts.Create(ctx, managedName, resourceLabels, data) } func inspectConfig(ctx context.Context, dockerClient *dockerclient.Client, name string) (resourceMeta, error) { @@ -1340,49 +1366,99 @@ func resolveRegistryAuthForSpec( } func cleanupStaleConfigs(ctx context.Context, dockerClient *dockerclient.Client, stackName string, desiredNames map[string]struct{}) error { - filters := make(dockerclient.Filters) - filters.Add("label", fmt.Sprintf("%s=%s", swarmtypes.StackNamespaceLabel, stackName)) - filters.Add("label", resourceTypeLabel+"=config") + return cleanupStaleManagedResourcesInternal(ctx, stackName, desiredNames, staleManagedResourceOptionsInternal{ + ResourceType: "config", + List: listStaleConfigResourcesInternal(dockerClient), + Remove: removeConfigResourceInternal(dockerClient), + }) +} - configsResult, err := dockerClient.ConfigList(ctx, dockerclient.ConfigListOptions{Filters: filters}) - if err != nil { - return fmt.Errorf("failed to list stack configs: %w", err) - } +func cleanupStaleSecrets(ctx context.Context, dockerClient *dockerclient.Client, stackName string, desiredNames map[string]struct{}) error { + return cleanupStaleManagedResourcesInternal(ctx, stackName, desiredNames, staleManagedResourceOptionsInternal{ + ResourceType: "secret", + List: listStaleSecretResourcesInternal(dockerClient), + Remove: removeSecretResourceInternal(dockerClient), + }) +} - for _, cfg := range configsResult.Items { - if _, ok := desiredNames[cfg.Spec.Name]; ok { - continue +func listStaleConfigResourcesInternal(dockerClient *dockerclient.Client) func(context.Context, dockerclient.Filters) ([]staleManagedResourceInternal, error) { + return func(ctx context.Context, filters dockerclient.Filters) ([]staleManagedResourceInternal, error) { + configsResult, err := dockerClient.ConfigList(ctx, dockerclient.ConfigListOptions{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("failed to list stack configs: %w", err) } - if _, err := dockerClient.ConfigRemove(ctx, cfg.ID, dockerclient.ConfigRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { - if isStaleSwarmResourceStillInUse(err) { - continue - } - return fmt.Errorf("failed to remove stale stack config %s: %w", cfg.Spec.Name, err) + + return collectStaleManagedResourcesInternal(configsResult.Items, func(cfg swarm.Config) staleManagedResourceInternal { + return staleManagedResourceInternal{ID: cfg.ID, Name: cfg.Spec.Name} + }), nil + } +} + +func listStaleSecretResourcesInternal(dockerClient *dockerclient.Client) func(context.Context, dockerclient.Filters) ([]staleManagedResourceInternal, error) { + return func(ctx context.Context, filters dockerclient.Filters) ([]staleManagedResourceInternal, error) { + secretsResult, err := dockerClient.SecretList(ctx, dockerclient.SecretListOptions{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("failed to list stack secrets: %w", err) } + + return collectStaleManagedResourcesInternal(secretsResult.Items, func(secret swarm.Secret) staleManagedResourceInternal { + return staleManagedResourceInternal{ID: secret.ID, Name: secret.Spec.Name} + }), nil } +} - return nil +func collectStaleManagedResourcesInternal[T any](items []T, convert func(T) staleManagedResourceInternal) []staleManagedResourceInternal { + resources := make([]staleManagedResourceInternal, 0, len(items)) + for _, item := range items { + resources = append(resources, convert(item)) + } + return resources } -func cleanupStaleSecrets(ctx context.Context, dockerClient *dockerclient.Client, stackName string, desiredNames map[string]struct{}) error { +func removeConfigResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string) error { + return func(ctx context.Context, id string) error { + _, err := dockerClient.ConfigRemove(ctx, id, dockerclient.ConfigRemoveOptions{}) + return err + } +} + +func removeSecretResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string) error { + return func(ctx context.Context, id string) error { + _, err := dockerClient.SecretRemove(ctx, id, dockerclient.SecretRemoveOptions{}) + return err + } +} + +type staleManagedResourceInternal struct { + ID string + Name string +} + +type staleManagedResourceOptionsInternal struct { + ResourceType string + List func(context.Context, dockerclient.Filters) ([]staleManagedResourceInternal, error) + Remove func(context.Context, string) error +} + +func cleanupStaleManagedResourcesInternal(ctx context.Context, stackName string, desiredNames map[string]struct{}, opts staleManagedResourceOptionsInternal) error { filters := make(dockerclient.Filters) filters.Add("label", fmt.Sprintf("%s=%s", swarmtypes.StackNamespaceLabel, stackName)) - filters.Add("label", resourceTypeLabel+"=secret") + filters.Add("label", resourceTypeLabel+"="+opts.ResourceType) - secretsResult, err := dockerClient.SecretList(ctx, dockerclient.SecretListOptions{Filters: filters}) + resources, err := opts.List(ctx, filters) if err != nil { - return fmt.Errorf("failed to list stack secrets: %w", err) + return err } - for _, secret := range secretsResult.Items { - if _, ok := desiredNames[secret.Spec.Name]; ok { + for _, resource := range resources { + if _, ok := desiredNames[resource.Name]; ok { continue } - if _, err := dockerClient.SecretRemove(ctx, secret.ID, dockerclient.SecretRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { + if err := opts.Remove(ctx, resource.ID); err != nil && !cerrdefs.IsNotFound(err) { if isStaleSwarmResourceStillInUse(err) { continue } - return fmt.Errorf("failed to remove stale stack secret %s: %w", secret.Spec.Name, err) + return fmt.Errorf("failed to remove stale stack %s %s: %w", opts.ResourceType, resource.Name, err) } } diff --git a/backend/pkg/pagination/db_utils.go b/backend/pkg/pagination/db_utils.go index 253474fd76..a8e3e58bc7 100644 --- a/backend/pkg/pagination/db_utils.go +++ b/backend/pkg/pagination/db_utils.go @@ -1,8 +1,10 @@ package pagination import ( + "fmt" "strings" + "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "gorm.io/gorm" ) @@ -23,6 +25,38 @@ func ApplyFilter(q *gorm.DB, column string, value string) *gorm.DB { return q.Where(column+" = ?", value) } +// ApplyLikeSearch adds a LIKE search condition using the same wildcard pattern +// for every placeholder in condition. +func ApplyLikeSearch(q *gorm.DB, search string, condition string) *gorm.DB { + term := strings.TrimSpace(search) + if term == "" { + return q + } + + pattern := "%" + term + "%" + args := make([]any, strings.Count(condition, "?")) + for i := range args { + args[i] = pattern + } + + return q.Where(condition, args...) +} + +// PaginateSortAndMapDB paginates DB records and maps them to API DTOs. +func PaginateSortAndMapDB[M any, D any](params QueryParams, query *gorm.DB, records *[]M) ([]D, Response, error) { + paginationResp, err := PaginateAndSortDB(params, query, records) + if err != nil { + return nil, Response{}, fmt.Errorf("paginate db records: %w", err) + } + + out, err := mapper.MapSlice[M, D](*records) + if err != nil { + return nil, Response{}, fmt.Errorf("map db records: %w", err) + } + + return out, paginationResp, nil +} + // ApplyBooleanFilter adds a WHERE clause for boolean columns. // It detects comma-separated values and maps "true"/"1" to true and "false"/"0" to false. func ApplyBooleanFilter(q *gorm.DB, column string, value string) *gorm.DB { diff --git a/backend/pkg/projects/fs_templates.go b/backend/pkg/projects/fs_templates.go index 20addb3698..50f9eac78a 100644 --- a/backend/pkg/projects/fs_templates.go +++ b/backend/pkg/projects/fs_templates.go @@ -31,8 +31,7 @@ func ReadFolderComposeTemplate(baseDir, folder string) (string, *string, string, for _, envName := range []string{".env.example", ".env"} { envPath := filepath.Join(folderPath, envName) if eb, rerr := os.ReadFile(envPath); rerr == nil { - s := string(eb) - envPtr = &s + envPtr = new(string(eb)) break } } diff --git a/backend/pkg/projects/image_refs.go b/backend/pkg/projects/image_refs.go new file mode 100644 index 0000000000..ccf8ef1596 --- /dev/null +++ b/backend/pkg/projects/image_refs.go @@ -0,0 +1,65 @@ +package projects + +import ( + "sort" + "strings" + + composetypes "github.com/compose-spec/compose-go/v2/types" + projecttypes "github.com/getarcaneapp/arcane/types/project" +) + +// ImageRefsFromComposeServices returns unique, non-empty image references from +// a compose service map in stable service-name order. +func ImageRefsFromComposeServices(services composetypes.Services) []string { + serviceNames := make([]string, 0, len(services)) + for name := range services { + serviceNames = append(serviceNames, name) + } + sort.Strings(serviceNames) + + serviceConfigs := make([]composetypes.ServiceConfig, 0, len(services)) + for _, name := range serviceNames { + serviceConfigs = append(serviceConfigs, services[name]) + } + + return ImageRefsFromComposeConfigs(serviceConfigs) +} + +// ImageRefsFromComposeConfigs returns unique, non-empty image references from +// compose service configs while preserving first-seen order. +func ImageRefsFromComposeConfigs(services []composetypes.ServiceConfig) []string { + return uniqueImageRefsInternal(len(services), func(yield func(string)) { + for _, svc := range services { + yield(svc.Image) + } + }) +} + +// ImageRefsFromRuntimeServices returns unique, non-empty image references from +// runtime service DTOs while preserving first-seen order. +func ImageRefsFromRuntimeServices(services []projecttypes.RuntimeService) []string { + return uniqueImageRefsInternal(len(services), func(yield func(string)) { + for _, svc := range services { + yield(svc.Image) + } + }) +} + +func uniqueImageRefsInternal(size int, collect func(yield func(string))) []string { + refs := make([]string, 0, size) + seen := make(map[string]struct{}, size) + + collect(func(image string) { + ref := strings.TrimSpace(image) + if ref == "" { + return + } + if _, exists := seen[ref]; exists { + return + } + seen[ref] = struct{}{} + refs = append(refs, ref) + }) + + return refs +} diff --git a/backend/pkg/scheduler/auto_heal_job.go b/backend/pkg/scheduler/auto_heal_job.go index 129e591973..98c146caf9 100644 --- a/backend/pkg/scheduler/auto_heal_job.go +++ b/backend/pkg/scheduler/auto_heal_job.go @@ -9,6 +9,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" @@ -124,7 +125,7 @@ func (j *AutoHealJob) filterCandidatesInternal(containers []container.Summary, e continue } - containerName := j.getContainerName(c.Names) + containerName := dockerutil.ContainerNameFromNames(c.Names) if j.isExcluded(containerName, excludedContainers) { continue } @@ -144,7 +145,7 @@ func (j *AutoHealJob) processCandidateInternal( restartWindowMinutes int, ) { containerID := candidate.ID - containerName := j.getContainerName(candidate.Names) + containerName := dockerutil.ContainerNameFromNames(candidate.Names) inspect, err := j.inspectContainerInternal(ctx, dockerClient, containerID) if err != nil { @@ -264,14 +265,6 @@ func (j *AutoHealJob) parseExcludedContainers(ctx context.Context) map[string]st return excluded } -func (j *AutoHealJob) getContainerName(names []string) string { - if len(names) == 0 { - return "" - } - // Docker container names are prefixed with "/" - return strings.TrimPrefix(names[0], "/") -} - func (j *AutoHealJob) isExcluded(name string, excluded map[string]struct{}) bool { _, ok := excluded[name] return ok diff --git a/backend/pkg/utils/httpx/request.go b/backend/pkg/utils/httpx/request.go index 4d179f08b4..eb01c4407e 100644 --- a/backend/pkg/utils/httpx/request.go +++ b/backend/pkg/utils/httpx/request.go @@ -8,6 +8,17 @@ import ( "strings" ) +type HeaderSetter interface { + SetHeader(key string, value string) +} + +func SetJSONStreamHeaders(headers HeaderSetter) { + headers.SetHeader("Content-Type", "application/x-json-stream") + headers.SetHeader("Cache-Control", "no-cache") + headers.SetHeader("Connection", "keep-alive") + headers.SetHeader("X-Accel-Buffering", "no") +} + // ValidateWebSocketOrigin validates the Origin header for WebSocket connections // to prevent CSRF attacks. It checks: // 1. Same-origin requests (Origin matches Host) diff --git a/backend/pkg/utils/notifications/messages.go b/backend/pkg/utils/notifications/messages.go index 2ff386dab5..43cf50c7b1 100644 --- a/backend/pkg/utils/notifications/messages.go +++ b/backend/pkg/utils/notifications/messages.go @@ -185,12 +185,12 @@ func BuildPruneReportNotificationMessage(format MessageFormat, environmentName s var message strings.Builder fmt.Fprintf(&message, "%s\n\n", formatNotificationTitleInternal(format, "🧹 System Prune Report")) fmt.Fprintf(&message, "%s %s\n", formatNotificationLabelInternal(format, "Environment"), environmentName) - fmt.Fprintf(&message, "%s %s\n\n", formatNotificationLabelInternal(format, "Total Space Reclaimed"), formatBytesInternal(result.SpaceReclaimed)) + fmt.Fprintf(&message, "%s %s\n\n", formatNotificationLabelInternal(format, "Total Space Reclaimed"), FormatBytes(result.SpaceReclaimed)) fmt.Fprintf(&message, "%s\n", formatNotificationLabelInternal(format, "Breakdown")) - fmt.Fprintf(&message, "- Containers: %s\n", formatBytesInternal(result.ContainerSpaceReclaimed)) - fmt.Fprintf(&message, "- Images: %s\n", formatBytesInternal(result.ImageSpaceReclaimed)) - fmt.Fprintf(&message, "- Volumes: %s\n", formatBytesInternal(result.VolumeSpaceReclaimed)) - fmt.Fprintf(&message, "- Build Cache: %s\n", formatBytesInternal(result.BuildCacheSpaceReclaimed)) + fmt.Fprintf(&message, "- Containers: %s\n", FormatBytes(result.ContainerSpaceReclaimed)) + fmt.Fprintf(&message, "- Images: %s\n", FormatBytes(result.ImageSpaceReclaimed)) + fmt.Fprintf(&message, "- Volumes: %s\n", FormatBytes(result.VolumeSpaceReclaimed)) + fmt.Fprintf(&message, "- Build Cache: %s\n", FormatBytes(result.BuildCacheSpaceReclaimed)) return message.String() } @@ -203,7 +203,7 @@ func BuildAutoHealNotificationMessage(format MessageFormat, environmentName, con return message.String() } -func formatBytesInternal(bytes uint64) string { +func FormatBytes(bytes uint64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) diff --git a/cli/go.mod b/cli/go.mod index 07b85167e3..4c1d021957 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,12 +5,12 @@ go 1.26.3 require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 + charm.land/fang/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.3 - github.com/charmbracelet/fang v1.0.0 - github.com/charmbracelet/log v1.0.0 + charm.land/log/v2 v2.0.0 github.com/charmbracelet/x/term v0.2.2 github.com/fatih/color v1.19.0 - github.com/getarcaneapp/arcane/types v1.19.4 + github.com/getarcaneapp/arcane/types v1.19.5 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 @@ -22,15 +22,12 @@ require ( require ( github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20260511125431-fe5d686e0c99 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect @@ -59,7 +56,6 @@ require ( github.com/muesli/mango-cobra v1.3.0 // indirect github.com/muesli/mango-pflag v0.2.0 // indirect github.com/muesli/roff v0.1.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect @@ -73,13 +69,13 @@ require ( github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index 28e1587a58..d89cf1f2d2 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -2,36 +2,30 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= -github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= -github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= -github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow= -github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20260511125431-fe5d686e0c99 h1:79Whx3H/thq9X9I+iqsi7o/pVaI7EhaIWbzB173eHsw= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20260511125431-fe5d686e0c99/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d h1:sMilwx1YIYTrQva6jsB522AoRYAerNaDIKP4ZPtUq0A= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -67,8 +61,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/getarcaneapp/arcane/types v1.19.4 h1:THWCdJPoUjM+PJYo/02eb0Q56xSiHOE8Xt2zxdP6BfY= -github.com/getarcaneapp/arcane/types v1.19.4/go.mod h1:YY2TkFWZlMNu5dwp8dIDivLXl8dd9TE+P55Mk+6CaZ8= +github.com/getarcaneapp/arcane/types v1.19.5 h1:NmdBfeQhJ+OETxnk2crN82DfQJ3DTbruWLZSq/JkE1c= +github.com/getarcaneapp/arcane/types v1.19.5/go.mod h1:Ts4t4KmeMu5S1Qc5Mg9qjvYft9O3EYnlfEzSi+9GNKE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -116,8 +110,6 @@ github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0 github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -158,26 +150,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.withmatt.com/size v0.0.0-20250220224316-11aee5773e67 h1:nHPqmzoLZv+9OqER7S2oXnIBDAbIaIKKrVtNtDvJzjc= go.withmatt.com/size v0.0.0-20250220224316-11aee5773e67/go.mod h1:WFmSrzphcXaSV4LG3YfIw42bPHDK8A6WsAYZCxyys/c= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 h1:cHpkPjp4TILjdZxz/O4ykwCpeS+dDqNuDGse4zgQDCk= +golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= diff --git a/cli/internal/logger/logger.go b/cli/internal/logger/logger.go index a7283b9d57..cce6f42be6 100644 --- a/cli/internal/logger/logger.go +++ b/cli/internal/logger/logger.go @@ -22,7 +22,7 @@ package logger import ( "os" - charmlog "github.com/charmbracelet/log" + charmlog "charm.land/log/v2" ) // Setup configures the global logger with the specified level and format. diff --git a/cli/pkg/admin/oidcmappings/cmd.go b/cli/pkg/admin/oidcmappings/cmd.go index 14d7895241..b3e699f061 100644 --- a/cli/pkg/admin/oidcmappings/cmd.go +++ b/cli/pkg/admin/oidcmappings/cmd.go @@ -101,8 +101,7 @@ var createCmd = &cobra.Command{ RoleID: createRoleID, } if cmd.Flags().Changed("environment") && createEnvironment != "" { - env := createEnvironment - req.EnvironmentID = &env + req.EnvironmentID = new(createEnvironment) } resp, err := c.Post(cmd.Context(), types.Endpoints.OidcRoleMappings(), req) @@ -164,8 +163,7 @@ var updateCmd = &cobra.Command{ // Empty value flips an env-scoped mapping back to global. req.EnvironmentID = nil } else { - env := updateEnvironment - req.EnvironmentID = &env + req.EnvironmentID = new(updateEnvironment) } } diff --git a/cli/pkg/admin/roles/cmd.go b/cli/pkg/admin/roles/cmd.go index cb2b6662e2..ed89f4e920 100644 --- a/cli/pkg/admin/roles/cmd.go +++ b/cli/pkg/admin/roles/cmd.go @@ -416,8 +416,7 @@ func parseAssignmentsInternal(tokens []string) []roletypes.UserAssignmentInput { roleID, envID, hasEnv := strings.Cut(token, ":") entry := roletypes.UserAssignmentInput{RoleID: roleID} if hasEnv && envID != "" { - env := envID - entry.EnvironmentID = &env + entry.EnvironmentID = new(envID) } out = append(out, entry) } diff --git a/cli/pkg/containers/cmd.go b/cli/pkg/containers/cmd.go index 312e30f8b5..3681ca22ad 100644 --- a/cli/pkg/containers/cmd.go +++ b/cli/pkg/containers/cmd.go @@ -264,39 +264,11 @@ var containersStartCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - path := types.Endpoints.ContainerStart(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to start container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[base.MessageResponse] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s started successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[base.MessageResponse](cmd, args[0], containerPostActionConfig[base.MessageResponse]{ + endpoint: types.Endpoints.ContainerStart, + failureMessage: "failed to start container", + successVerb: "started", + }) }, } @@ -306,39 +278,11 @@ var containersStopCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - path := types.Endpoints.ContainerStop(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to stop container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[base.MessageResponse] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s stopped successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[base.MessageResponse](cmd, args[0], containerPostActionConfig[base.MessageResponse]{ + endpoint: types.Endpoints.ContainerStop, + failureMessage: "failed to stop container", + successVerb: "stopped", + }) }, } @@ -348,39 +292,11 @@ var containersRestartCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - path := types.Endpoints.ContainerRestart(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to restart container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[base.MessageResponse] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s restarted successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[base.MessageResponse](cmd, args[0], containerPostActionConfig[base.MessageResponse]{ + endpoint: types.Endpoints.ContainerRestart, + failureMessage: "failed to restart container", + successVerb: "restarted", + }) }, } @@ -390,42 +306,12 @@ var containersUpdateCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - // Updating a container can take a long time as it pulls the image - c.SetTimeout(30 * time.Minute) - - path := types.Endpoints.ContainerUpdate(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to update container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[container.ActionResult] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s updated successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[container.ActionResult](cmd, args[0], containerPostActionConfig[container.ActionResult]{ + endpoint: types.Endpoints.ContainerUpdate, + failureMessage: "failed to update container", + successVerb: "updated", + timeout: 30 * time.Minute, + }) }, } @@ -435,42 +321,60 @@ var containersRedeployCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } + return runContainerPostAction[container.Details](cmd, args[0], containerPostActionConfig[container.Details]{ + endpoint: types.Endpoints.ContainerRedeploy, + failureMessage: "failed to redeploy container", + successVerb: "redeployed", + timeout: 30 * time.Minute, + }) + }, +} - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } +type containerPostActionConfig[T any] struct { + endpoint func(string, string) string + failureMessage string + successVerb string + timeout time.Duration +} - c.SetTimeout(30 * time.Minute) +func runContainerPostAction[T any](cmd *cobra.Command, containerRef string, cfg containerPostActionConfig[T]) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } - path := types.Endpoints.ContainerRedeploy(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to redeploy container: %w", err) - } - defer func() { _ = resp.Body.Close() }() + resolved, _, err := resolveContainer(cmd.Context(), c, containerRef, false) + if err != nil { + return err + } - var result base.ApiResponse[container.Details] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } + if cfg.timeout > 0 { + c.SetTimeout(cfg.timeout) + } - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } + path := cfg.endpoint(c.EnvID(), resolved.ID) + resp, err := c.Post(cmd.Context(), path, nil) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() - output.Success("Container %s redeployed successfully", containerDisplayName(resolved)) + var result base.ApiResponse[T] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if jsonOutput { + resultBytes, err := json.MarshalIndent(result.Data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(resultBytes)) return nil - }, + } + + output.Success("Container %s %s successfully", containerDisplayName(resolved), cfg.successVerb) + return nil } var containersDeleteCmd = &cobra.Command{ diff --git a/cli/pkg/projects/cmd.go b/cli/pkg/projects/cmd.go index 039a590737..51fcd94c01 100644 --- a/cli/pkg/projects/cmd.go +++ b/cli/pkg/projects/cmd.go @@ -278,27 +278,11 @@ var upCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectUp(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to start project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to start project: %w", err) - } - - output.Success("Project %s started successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectUp, + failureMessage: "failed to start project", + successMessage: "Project %s started successfully", + }) }, } @@ -308,27 +292,11 @@ var downCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectDown(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to stop project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to stop project: %w", err) - } - - output.Success("Project %s stopped successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectDown, + failureMessage: "failed to stop project", + successMessage: "Project %s stopped successfully", + }) }, } @@ -338,27 +306,11 @@ var restartCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectRestart(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to restart project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to restart project: %w", err) - } - - output.Success("Project %s restarted successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectRestart, + failureMessage: "failed to restart project", + successMessage: "Project %s restarted successfully", + }) }, } @@ -368,30 +320,12 @@ var redeployCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - // Redeploying can take a long time as it pulls images and restarts containers - c.SetTimeout(30 * time.Minute) - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectRedeploy(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to redeploy project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to redeploy project: %w", err) - } - - output.Success("Project %s redeployed successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectRedeploy, + failureMessage: "failed to redeploy project", + successMessage: "Project %s redeployed successfully", + timeout: 30 * time.Minute, + }) }, } @@ -401,31 +335,48 @@ var pullCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectPull, + failureMessage: "failed to pull images", + successMessage: "Images pulled successfully for project %s", + timeout: 30 * time.Minute, + }) + }, +} - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } +type projectPostActionConfig struct { + endpoint func(string, string) string + failureMessage string + successMessage string + timeout time.Duration +} - // Pulling images can take a long time - c.SetTimeout(30 * time.Minute) +func runProjectPostAction(cmd *cobra.Command, projectRef string, cfg projectPostActionConfig) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectPull(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to pull images: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to pull images: %w", err) - } + resolved, _, err := resolveProject(cmd.Context(), c, projectRef, false) + if err != nil { + return err + } - output.Success("Images pulled successfully for project %s", resolved.Name) - return nil - }, + if cfg.timeout > 0 { + c.SetTimeout(cfg.timeout) + } + + resp, err := c.Post(cmd.Context(), cfg.endpoint(c.EnvID(), resolved.ID), nil) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + + output.Success(cfg.successMessage, resolved.Name) + return nil } var createCmd = &cobra.Command{ diff --git a/cli/pkg/root.go b/cli/pkg/root.go index 699233cf4f..2e1f1a4933 100644 --- a/cli/pkg/root.go +++ b/cli/pkg/root.go @@ -39,8 +39,8 @@ import ( "strings" "time" + "charm.land/fang/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/fang" "github.com/fatih/color" "github.com/getarcaneapp/arcane/cli/internal/config" "github.com/getarcaneapp/arcane/cli/internal/logger" diff --git a/cli/pkg/settings/cmd.go b/cli/pkg/settings/cmd.go index bb4c0a6e1a..88f5937899 100644 --- a/cli/pkg/settings/cmd.go +++ b/cli/pkg/settings/cmd.go @@ -28,44 +28,11 @@ var listCmd = &cobra.Command{ Short: "List environment settings", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.Settings(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get settings: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result []settings.PublicSetting - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - headers := []string{"KEY", "TYPE", "VALUE"} - rows := make([][]string, len(result)) - for i, s := range result { - rows[i] = []string{ - s.Key, - s.Type, - s.Value, - } - } - - output.Table(headers, rows) - fmt.Printf("\nTotal: %d settings\n", len(result)) - return nil + return runSettingsList(cmd, settingsListConfig{ + endpoint: types.Endpoints.Settings, + failureMessage: "failed to get settings", + totalLabel: "settings", + }) }, } @@ -120,41 +87,55 @@ var publicCmd = &cobra.Command{ Short: "List public settings", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.SettingsPublic(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get public settings: %w", err) - } - defer func() { _ = resp.Body.Close() }() + return runSettingsList(cmd, settingsListConfig{ + endpoint: types.Endpoints.SettingsPublic, + failureMessage: "failed to get public settings", + totalLabel: "public settings", + }) + }, +} - var result []settings.PublicSetting - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } +type settingsListConfig struct { + endpoint func(string) string + failureMessage string + totalLabel string +} - if jsonOutput { - resultBytes, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil +func runSettingsList(cmd *cobra.Command, cfg settingsListConfig) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } + + resp, err := c.Get(cmd.Context(), cfg.endpoint(c.EnvID())) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + + var result []settings.PublicSetting + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if jsonOutput { + resultBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) } + fmt.Println(string(resultBytes)) + return nil + } - headers := []string{"KEY", "TYPE", "VALUE"} - rows := make([][]string, len(result)) - for i, s := range result { - rows[i] = []string{s.Key, s.Type, s.Value} - } + headers := []string{"KEY", "TYPE", "VALUE"} + rows := make([][]string, len(result)) + for i, s := range result { + rows[i] = []string{s.Key, s.Type, s.Value} + } - output.Table(headers, rows) - fmt.Printf("\nTotal: %d public settings\n", len(result)) - return nil - }, + output.Table(headers, rows) + fmt.Printf("\nTotal: %d %s\n", len(result), cfg.totalLabel) + return nil } func init() { diff --git a/cli/pkg/volumes/cmd.go b/cli/pkg/volumes/cmd.go index 15caa46c0f..b0837968ee 100644 --- a/cli/pkg/volumes/cmd.go +++ b/cli/pkg/volumes/cmd.go @@ -196,38 +196,12 @@ var countsCmd = &cobra.Command{ Short: "Get volume usage counts", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.VolumesCounts(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get volume counts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Header("Volume Usage Counts") - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal volume counts: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return runVolumeDataCommand(cmd, volumeDataCommandConfig{ + endpoint: types.Endpoints.VolumesCounts, + failureMessage: "failed to get volume counts", + header: "Volume Usage Counts", + marshalMessage: "failed to marshal volume counts", + }) }, } @@ -285,39 +259,55 @@ var sizesCmd = &cobra.Command{ Short: "Get volume sizes", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } + return runVolumeDataCommand(cmd, volumeDataCommandConfig{ + endpoint: types.Endpoints.VolumesSizes, + failureMessage: "failed to get volume sizes", + header: "Volume Sizes", + marshalMessage: "failed to marshal volume sizes", + }) + }, +} - resp, err := c.Get(cmd.Context(), types.Endpoints.VolumesSizes(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get volume sizes: %w", err) - } - defer func() { _ = resp.Body.Close() }() +type volumeDataCommandConfig struct { + endpoint func(string) string + failureMessage string + header string + marshalMessage string +} - var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } +func runVolumeDataCommand(cmd *cobra.Command, cfg volumeDataCommandConfig) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), cfg.endpoint(c.EnvID())) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[any] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + resultBytes, err := json.MarshalIndent(result.Data, "", " ") + if err != nil { if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return fmt.Errorf("failed to marshal JSON: %w", err) } + return fmt.Errorf("%s: %w", cfg.marshalMessage, err) + } - output.Header("Volume Sizes") - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal volume sizes: %w", err) - } + if jsonOutput { fmt.Println(string(resultBytes)) return nil - }, + } + + output.Header("%s", cfg.header) + fmt.Println(string(resultBytes)) + return nil } var usageCmd = &cobra.Command{ diff --git a/go.work.sum b/go.work.sum index 44af362de0..675d348dbf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1151,9 +1151,13 @@ go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJ go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= diff --git a/types/go.mod b/types/go.mod index 9ce9e6bb1d..7f692e3fcf 100644 --- a/types/go.mod +++ b/types/go.mod @@ -29,10 +29,10 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/types/go.sum b/types/go.sum index 1c1ef89a1a..ca6da9b27d 100644 --- a/types/go.sum +++ b/types/go.sum @@ -51,18 +51,12 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= From e6914a7582977ea6fd4d64db99ef93152c1b2e4e Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 29 May 2026 12:28:47 -0500 Subject: [PATCH 19/40] refactor: modernize backend code (#2760) --- backend/api/handlers/projects.go | 2 +- backend/api/handlers/swarm.go | 2 +- backend/cli/generate/generate.go | 3 +-- .../internal/bootstrap/router_bootstrap.go | 2 +- .../middleware/rate_limit_middleware_test.go | 2 +- backend/internal/services/activity_service.go | 19 ++++---------- backend/internal/services/build_service.go | 2 +- .../services/container_registry_service.go | 2 +- .../services/docker_client_service.go | 2 +- .../services/git_repository_service.go | 2 +- .../services/image_update_service_test.go | 2 +- backend/internal/services/project_service.go | 2 +- .../internal/services/project_service_test.go | 2 +- backend/pkg/authz/permission_set.go | 6 ++--- backend/pkg/gitutil/git_test.go | 25 +++---------------- backend/pkg/libarcane/activity/writer.go | 5 +--- backend/pkg/libarcane/edge/tls_test.go | 4 +-- backend/pkg/libarcane/imageupdate/digest.go | 2 +- .../pkg/libarcane/imageupdate/digest_test.go | 2 +- .../imageupdate/registry_http_test.go | 2 +- backend/pkg/projects/load.go | 8 +++--- backend/pkg/remenv/client.go | 5 ++-- backend/pkg/scheduler/analytics_job.go | 2 +- types/container/container.go | 2 +- types/image/image.go | 2 +- types/imageupdate/image_update.go | 2 +- 26 files changed, 40 insertions(+), 71 deletions(-) diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index 431399c482..fa49dfee3e 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -17,7 +17,7 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/authz" activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" - projects "github.com/getarcaneapp/arcane/backend/pkg/projects" + "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" diff --git a/backend/api/handlers/swarm.go b/backend/api/handlers/swarm.go index ddf3f9d0a9..2bcf1ef50e 100644 --- a/backend/api/handlers/swarm.go +++ b/backend/api/handlers/swarm.go @@ -1165,7 +1165,7 @@ func (h *SwarmHandler) GetStack(ctx context.Context, input *GetSwarmStackInput) stack, err := h.swarmService.GetStack(ctx, input.EnvironmentID, input.Name) if err != nil { if errdefs.IsNotFound(err) { - return nil, huma.Error404NotFound(("Swarm stack not found")) + return nil, huma.Error404NotFound("Swarm stack not found") } return nil, mapSwarmServiceError(err, "Failed to inspect swarm stack") } diff --git a/backend/cli/generate/generate.go b/backend/cli/generate/generate.go index 95371b9c72..d22679a9d3 100644 --- a/backend/cli/generate/generate.go +++ b/backend/cli/generate/generate.go @@ -2,7 +2,6 @@ package generate import ( cligenerate "github.com/getarcaneapp/arcane/cli/pkg/generate" - "github.com/spf13/cobra" ) -var GenerateCmd *cobra.Command = cligenerate.GenerateCmd +var GenerateCmd = cligenerate.GenerateCmd diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index a937fdab28..0815511696 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -265,7 +265,7 @@ func parseTrustedProxyCIDRsInternal(raw string) []*net.IPNet { return nil } var nets []*net.IPNet - for _, cidr := range strings.Split(raw, ",") { + for cidr := range strings.SplitSeq(raw, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue diff --git a/backend/internal/middleware/rate_limit_middleware_test.go b/backend/internal/middleware/rate_limit_middleware_test.go index 0d3768d1a0..f88525bb7d 100644 --- a/backend/internal/middleware/rate_limit_middleware_test.go +++ b/backend/internal/middleware/rate_limit_middleware_test.go @@ -91,7 +91,7 @@ func TestPerIPRateLimitForPaths_AppliesOnlyToConfiguredPaths(t *testing.T) { require.Equal(t, http.StatusOK, doReq("/limited")) require.Equal(t, http.StatusTooManyRequests, doReq("/limited")) - for i := 0; i < 10; i++ { + for range 10 { require.Equal(t, http.StatusOK, doReq("/unlimited")) } } diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go index bcc40abec7..7c00bd567c 100644 --- a/backend/internal/services/activity_service.go +++ b/backend/internal/services/activity_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "strings" "sync" "time" @@ -768,10 +769,7 @@ func clampProgressPtrInternal(value *int) *int { if value == nil { return nil } - clamped := *value - if clamped < 0 { - clamped = 0 - } + clamped := max(*value, 0) if clamped > 100 { clamped = 100 } @@ -783,9 +781,7 @@ func cloneJSONInternal(input models.JSON) models.JSON { return nil } out := make(models.JSON, len(input)) - for key, value := range input { - out[key] = value - } + maps.Copy(out, input) return out } @@ -794,9 +790,7 @@ func jsonToMapInternal(input models.JSON) map[string]any { return nil } out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } + maps.Copy(out, input) return out } @@ -847,10 +841,7 @@ func deleteActivitiesByIDInternal(tx *gorm.DB, activityIDs []string) (int64, err var totalDeleted int64 for i := 0; i < len(activityIDs); i += deleteActivitiesBatchSize { - end := i + deleteActivitiesBatchSize - if end > len(activityIDs) { - end = len(activityIDs) - } + end := min(i+deleteActivitiesBatchSize, len(activityIDs)) batch := activityIDs[i:end] if err := tx.Where("activity_id IN ?", batch).Delete(&models.ActivityMessage{}).Error; err != nil { diff --git a/backend/internal/services/build_service.go b/backend/internal/services/build_service.go index a713436a4e..0ffd6f5f5a 100644 --- a/backend/internal/services/build_service.go +++ b/backend/internal/services/build_service.go @@ -16,7 +16,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" buildgit "github.com/getarcaneapp/arcane/backend/pkg/gitutil" - libbuild "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" "github.com/getarcaneapp/arcane/backend/pkg/pagination" buildtypes "github.com/getarcaneapp/arcane/types/builds" imagetypes "github.com/getarcaneapp/arcane/types/image" diff --git a/backend/internal/services/container_registry_service.go b/backend/internal/services/container_registry_service.go index 2cbcf1fc4f..d594e886d5 100644 --- a/backend/internal/services/container_registry_service.go +++ b/backend/internal/services/container_registry_service.go @@ -11,7 +11,7 @@ import ( "sync" "time" - backoff "github.com/cenkalti/backoff/v5" + "github.com/cenkalti/backoff/v5" "golang.org/x/sync/singleflight" cerrdefs "github.com/containerd/errdefs" diff --git a/backend/internal/services/docker_client_service.go b/backend/internal/services/docker_client_service.go index a12ea22496..6830592b60 100644 --- a/backend/internal/services/docker_client_service.go +++ b/backend/internal/services/docker_client_service.go @@ -10,7 +10,7 @@ import ( "sync" "time" - backoff "github.com/cenkalti/backoff/v5" + "github.com/cenkalti/backoff/v5" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" diff --git a/backend/internal/services/git_repository_service.go b/backend/internal/services/git_repository_service.go index d44c97ae61..651a8c00dd 100644 --- a/backend/internal/services/git_repository_service.go +++ b/backend/internal/services/git_repository_service.go @@ -10,7 +10,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" git "github.com/getarcaneapp/arcane/backend/pkg/gitutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" - libbuild "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" diff --git a/backend/internal/services/image_update_service_test.go b/backend/internal/services/image_update_service_test.go index 786773ae20..2b243a6172 100644 --- a/backend/internal/services/image_update_service_test.go +++ b/backend/internal/services/image_update_service_test.go @@ -21,7 +21,7 @@ import ( dockertypesimage "github.com/moby/moby/api/types/image" dockerregistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index d4eeed9bdb..e86f1cdbcf 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -25,7 +25,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" - libbuild "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/projects" diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index c0459c44f1..e138e5b616 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -31,7 +31,7 @@ import ( glsqlite "github.com/glebarez/sqlite" "github.com/moby/moby/api/types/container" dockertypesimage "github.com/moby/moby/api/types/image" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" diff --git a/backend/pkg/authz/permission_set.go b/backend/pkg/authz/permission_set.go index dc76644617..ad7c96633e 100644 --- a/backend/pkg/authz/permission_set.go +++ b/backend/pkg/authz/permission_set.go @@ -113,14 +113,14 @@ func EnvIDFromPath(path string) string { return "" } rest := path[len("/environments/"):] - slash := strings.Index(rest, "/") - if slash == -1 { + before, _, ok := strings.Cut(rest, "/") + if !ok { // Path is just /environments/ (no trailing segment) — this is // not an env-scoped operation, it's the env detail endpoint itself, // which is org-level. return "" } - id := rest[:slash] + id := before if id == "" { return "" } diff --git a/backend/pkg/gitutil/git_test.go b/backend/pkg/gitutil/git_test.go index a3b330a039..b6a3fb940b 100644 --- a/backend/pkg/gitutil/git_test.go +++ b/backend/pkg/gitutil/git_test.go @@ -6,6 +6,7 @@ import ( "net" "os" "path/filepath" + "slices" "strings" "sync" "testing" @@ -522,13 +523,7 @@ func TestWalkDirectory_ComposeInSubdirectory(t *testing.T) { t.Fatalf("expected %d files, got %d (%v)", len(expected), len(paths), paths) } for _, want := range expected { - found := false - for _, got := range paths { - if got == want { - found = true - break - } - } + found := slices.Contains(paths, want) if !found { t.Fatalf("expected walked paths to include %q, got %v", want, paths) } @@ -556,13 +551,7 @@ func TestWalkDirectory_NestedSiblingFile(t *testing.T) { t.Fatalf("expected %d files, got %d (%v)", len(expected), len(paths), paths) } for _, want := range expected { - found := false - for _, got := range paths { - if got == want { - found = true - break - } - } + found := slices.Contains(paths, want) if !found { t.Fatalf("expected walked paths to include %q, got %v", want, paths) } @@ -590,13 +579,7 @@ func TestWalkDirectory_SpecialCharsInPath(t *testing.T) { t.Fatalf("expected %d files, got %d (%v)", len(expected), len(paths), paths) } for _, want := range expected { - found := false - for _, got := range paths { - if got == want { - found = true - break - } - } + found := slices.Contains(paths, want) if !found { t.Fatalf("expected walked paths to include %q, got %v", want, paths) } diff --git a/backend/pkg/libarcane/activity/writer.go b/backend/pkg/libarcane/activity/writer.go index 2577d645bc..2fb4c8052f 100644 --- a/backend/pkg/libarcane/activity/writer.go +++ b/backend/pkg/libarcane/activity/writer.go @@ -224,10 +224,7 @@ func (w *Writer) updateLayerProgressInternal(id, status string, rawDetail any) * } } - progress := int((weighted / float64(len(w.layers))) * 100) - if progress > 100 { - progress = 100 - } + progress := min(int((weighted/float64(len(w.layers)))*100), 100) if progress < 0 { progress = 0 } diff --git a/backend/pkg/libarcane/edge/tls_test.go b/backend/pkg/libarcane/edge/tls_test.go index 626faddadf..bcd28be0d0 100644 --- a/backend/pkg/libarcane/edge/tls_test.go +++ b/backend/pkg/libarcane/edge/tls_test.go @@ -215,7 +215,7 @@ func TestEnsureAgentMTLSAssets_UsesDownloadedCAPathWhenPresent(t *testing.T) { func TestRemoveStaleEdgeMTLSLockInternal_PreservesLivePID(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".ca.lock") - require.NoError(t, os.WriteFile(lockPath, []byte(fmt.Sprintf("%d %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339Nano))), 0o600)) + require.NoError(t, os.WriteFile(lockPath, fmt.Appendf(nil, "%d %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339Nano)), 0o600)) require.False(t, removeStaleEdgeMTLSLockInternal(lockPath)) require.FileExists(t, lockPath) @@ -224,7 +224,7 @@ func TestRemoveStaleEdgeMTLSLockInternal_PreservesLivePID(t *testing.T) { func TestRemoveStaleEdgeMTLSLockInternal_RemovesOldLockDespiteLivePID(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".ca.lock") oldTimestamp := time.Now().Add(-(2*managerCALockTimeout + time.Second)).UTC().Format(time.RFC3339Nano) - require.NoError(t, os.WriteFile(lockPath, []byte(fmt.Sprintf("%d %s\n", os.Getpid(), oldTimestamp)), 0o600)) + require.NoError(t, os.WriteFile(lockPath, fmt.Appendf(nil, "%d %s\n", os.Getpid(), oldTimestamp), 0o600)) require.True(t, removeStaleEdgeMTLSLockInternal(lockPath)) require.NoFileExists(t, lockPath) diff --git a/backend/pkg/libarcane/imageupdate/digest.go b/backend/pkg/libarcane/imageupdate/digest.go index e37974fa70..7e8b21a354 100644 --- a/backend/pkg/libarcane/imageupdate/digest.go +++ b/backend/pkg/libarcane/imageupdate/digest.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/moby/moby/client" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" ) type remoteDigestResolver interface { diff --git a/backend/pkg/libarcane/imageupdate/digest_test.go b/backend/pkg/libarcane/imageupdate/digest_test.go index 0339f7f293..f2d3a0fbf0 100644 --- a/backend/pkg/libarcane/imageupdate/digest_test.go +++ b/backend/pkg/libarcane/imageupdate/digest_test.go @@ -3,7 +3,7 @@ package imageupdate import ( "testing" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/backend/pkg/libarcane/imageupdate/registry_http_test.go b/backend/pkg/libarcane/imageupdate/registry_http_test.go index 35218ef3fe..db6ea3959e 100644 --- a/backend/pkg/libarcane/imageupdate/registry_http_test.go +++ b/backend/pkg/libarcane/imageupdate/registry_http_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/backend/pkg/projects/load.go b/backend/pkg/projects/load.go index e90733bdf5..6fbeae7343 100644 --- a/backend/pkg/projects/load.go +++ b/backend/pkg/projects/load.go @@ -19,7 +19,7 @@ import ( "github.com/compose-spec/compose-go/v2/tree" composetypes "github.com/compose-spec/compose-go/v2/types" "github.com/docker/compose/v5/pkg/api" - units "github.com/docker/go-units" + "github.com/docker/go-units" ) var errComposeFileNotFoundInternal = errors.New("no compose file found") @@ -184,7 +184,7 @@ func wrapTypeCastMappingLenientInternal(mapping map[tree.Path]interp.Cast) map[t } func wrapCastWithLenientFallbackInternal(original interp.Cast) interp.Cast { - return func(value string) (interface{}, error) { + return func(value string) (any, error) { result, err := original(value) if err == nil { return result, nil @@ -196,11 +196,11 @@ func wrapCastWithLenientFallbackInternal(original interp.Cast) interp.Cast { } } -func lenientCastFloatInternal(value string) (interface{}, error) { +func lenientCastFloatInternal(value string) (any, error) { return strconv.ParseFloat(value, 64) } -func lenientCastSizeInternal(value string) (interface{}, error) { +func lenientCastSizeInternal(value string) (any, error) { n, err := strconv.ParseInt(value, 10, 64) if err != nil { b, parseErr := units.RAMInBytes(value) diff --git a/backend/pkg/remenv/client.go b/backend/pkg/remenv/client.go index 7a151e8683..40270b24bf 100644 --- a/backend/pkg/remenv/client.go +++ b/backend/pkg/remenv/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "net/http" "strings" "time" @@ -297,8 +298,6 @@ func cloneHeaders(headers map[string]string) map[string]string { } out := make(map[string]string, len(headers)) - for key, value := range headers { - out[key] = value - } + maps.Copy(out, headers) return out } diff --git a/backend/pkg/scheduler/analytics_job.go b/backend/pkg/scheduler/analytics_job.go index 12b0a2f60b..08cc3999dd 100644 --- a/backend/pkg/scheduler/analytics_job.go +++ b/backend/pkg/scheduler/analytics_job.go @@ -12,7 +12,7 @@ import ( "sync" "time" - backoff "github.com/cenkalti/backoff/v5" + "github.com/cenkalti/backoff/v5" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/services" ) diff --git a/types/container/container.go b/types/container/container.go index a86bab3eb2..a3c5cda423 100644 --- a/types/container/container.go +++ b/types/container/container.go @@ -6,7 +6,7 @@ import ( "strings" "time" - containerregistry "github.com/getarcaneapp/arcane/types/containerregistry" + "github.com/getarcaneapp/arcane/types/containerregistry" imagetypes "github.com/getarcaneapp/arcane/types/image" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/network" diff --git a/types/image/image.go b/types/image/image.go index dc8d051730..aaba3eb49c 100644 --- a/types/image/image.go +++ b/types/image/image.go @@ -5,7 +5,7 @@ import ( "strings" "time" - containerregistry "github.com/getarcaneapp/arcane/types/containerregistry" + "github.com/getarcaneapp/arcane/types/containerregistry" "github.com/getarcaneapp/arcane/types/vulnerability" "github.com/moby/moby/api/types/image" ) diff --git a/types/imageupdate/image_update.go b/types/imageupdate/image_update.go index 1639f1f669..2991711b84 100644 --- a/types/imageupdate/image_update.go +++ b/types/imageupdate/image_update.go @@ -3,7 +3,7 @@ package imageupdate import ( "time" - containerregistry "github.com/getarcaneapp/arcane/types/containerregistry" + "github.com/getarcaneapp/arcane/types/containerregistry" ) type Response struct { From 50d239ffe3113825986d77d7c7b7f05c7c0a91c2 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 29 May 2026 17:23:33 -0500 Subject: [PATCH 20/40] feat: better universal dashboard ui tweaks (#2761) --- backend/api/handlers/dashboard.go | 101 --- backend/api/handlers/dashboard_test.go | 36 - .../internal/services/dashboard_service.go | 356 +--------- .../services/dashboard_service_test.go | 364 ---------- cli/internal/types/endpoints.go | 8 +- cli/pkg/alerts/cmd.go | 101 --- cli/pkg/root.go | 3 - docs/rbac.md | 180 ----- frontend/messages/en.json | 34 +- frontend/src/app.css | 103 +-- frontend/src/app.html | 2 +- .../arcane-table-desktop-view.svelte | 2 +- .../lib/components/badges/port-badge.svelte | 6 +- .../lib/components/badges/status-badge.svelte | 2 +- .../dialogs/docker-info-dialog.svelte | 304 +++++--- .../src/lib/components/header-card.svelte | 5 +- .../src/lib/components/quick-actions.svelte | 97 --- frontend/src/lib/components/stat-card.svelte | 21 +- .../src/lib/components/tab-bar/tab-bar.svelte | 2 +- .../lib/components/ui/card/card-header.svelte | 46 +- .../src/lib/components/ui/card/card.svelte | 12 +- .../ui/drawer/drawer-content.svelte | 2 +- .../components/ui/sheet/sheet-content.svelte | 2 +- .../lib/components/ui/tabs/tabs-list.svelte | 2 +- .../components/ui/tabs/tabs-trigger.svelte | 2 +- .../lib/layouts/resource-detail-layout.svelte | 6 +- .../lib/layouts/resource-page-layout.svelte | 24 +- .../lib/layouts/settings-page-layout.svelte | 18 +- .../src/lib/layouts/tabbed-page-layout.svelte | 2 +- frontend/src/lib/query/query-keys.ts | 7 - .../src/lib/services/dashboard-service.ts | 30 +- frontend/src/lib/types/shared.ts | 19 +- .../src/routes/(app)/dashboard/+page.svelte | 661 +----------------- frontend/src/routes/(app)/dashboard/+page.ts | 43 +- .../(app)/dashboard/dash-action-card.svelte | 2 +- .../dashboard-all-environments-view.svelte | 423 +++++++---- .../src/routes/(app)/settings/+page.svelte | 6 +- tests/spec/dashboard-system-stats.spec.ts | 25 +- types/dashboard/dashboard.go | 110 --- 39 files changed, 629 insertions(+), 2540 deletions(-) delete mode 100644 cli/pkg/alerts/cmd.go delete mode 100644 docs/rbac.md diff --git a/backend/api/handlers/dashboard.go b/backend/api/handlers/dashboard.go index c46a78afde..4d95e2ce26 100644 --- a/backend/api/handlers/dashboard.go +++ b/backend/api/handlers/dashboard.go @@ -25,23 +25,6 @@ type GetDashboardOutput struct { Body base.ApiResponse[dashboardtypes.Snapshot] } -type GetDashboardActionItemsInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` - DebugAllGood bool `query:"debugAllGood" default:"false" doc:"Debug mode: force an empty action item list"` -} - -type GetDashboardActionItemsOutput struct { - Body base.ApiResponse[dashboardtypes.ActionItems] -} - -type GetDashboardEnvironmentsOverviewInput struct { - DebugAllGood bool `query:"debugAllGood" default:"false" doc:"Debug mode: force empty action item lists"` -} - -type GetDashboardEnvironmentsOverviewOutput struct { - Body base.ApiResponse[dashboardtypes.EnvironmentsOverview] -} - func RegisterDashboard(api huma.API, dashboardService *services.DashboardService) { h := &DashboardHandler{dashboardService: dashboardService} @@ -58,34 +41,6 @@ func RegisterDashboard(api huma.API, dashboardService *services.DashboardService }, Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), }, h.GetDashboard) - - huma.Register(api, huma.Operation{ - OperationID: "get-dashboard-action-items", - Method: http.MethodGet, - Path: "/environments/{id}/dashboard/action-items", - Summary: "Get dashboard action items", - Description: "Returns only dashboard action items that currently need attention", - Tags: []string{"Dashboard"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), - }, h.GetActionItems) - - huma.Register(api, huma.Operation{ - OperationID: "get-dashboard-environments-overview", - Method: http.MethodGet, - Path: "/dashboard/environments", - Summary: "Get aggregate environments dashboard overview", - Description: "Returns dashboard summary data for all visible environments", - Tags: []string{"Dashboard"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), - }, h.GetEnvironmentsOverview) } func (h *DashboardHandler) GetDashboard(ctx context.Context, input *GetDashboardInput) (*GetDashboardOutput, error) { @@ -114,59 +69,3 @@ func (h *DashboardHandler) GetDashboard(ctx context.Context, input *GetDashboard }, }, nil } - -func (h *DashboardHandler) GetActionItems(ctx context.Context, input *GetDashboardActionItemsInput) (*GetDashboardActionItemsOutput, error) { - if h.dashboardService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - // EnvironmentID is consumed by env proxy/auth middleware for routing/validation. - _ = input.EnvironmentID - - actionItems, err := h.dashboardService.GetActionItems(ctx, services.DashboardActionItemsOptions{ - DebugAllGood: input.DebugAllGood, - }) - if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) - } - - if actionItems == nil { - actionItems = &dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}} - } else if actionItems.Items == nil { - actionItems.Items = []dashboardtypes.ActionItem{} - } - - return &GetDashboardActionItemsOutput{ - Body: base.ApiResponse[dashboardtypes.ActionItems]{ - Success: true, - Data: *actionItems, - }, - }, nil -} - -func (h *DashboardHandler) GetEnvironmentsOverview( - ctx context.Context, - input *GetDashboardEnvironmentsOverviewInput, -) (*GetDashboardEnvironmentsOverviewOutput, error) { - if h.dashboardService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - overview, err := h.dashboardService.GetEnvironmentsOverview(ctx, services.DashboardActionItemsOptions{ - DebugAllGood: input.DebugAllGood, - }) - if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) - } - - if overview == nil { - return nil, huma.Error500InternalServerError("dashboard environments overview not available") - } - - return &GetDashboardEnvironmentsOverviewOutput{ - Body: base.ApiResponse[dashboardtypes.EnvironmentsOverview]{ - Success: true, - Data: *overview, - }, - }, nil -} diff --git a/backend/api/handlers/dashboard_test.go b/backend/api/handlers/dashboard_test.go index 70b90ac3c4..fd65304bb9 100644 --- a/backend/api/handlers/dashboard_test.go +++ b/backend/api/handlers/dashboard_test.go @@ -132,39 +132,3 @@ func TestDashboardHandlerGetDashboardReturnsSnapshot(t *testing.T) { {Kind: dashboardtypes.ActionItemKindExpiringKeys, Count: 1, Severity: dashboardtypes.ActionItemSeverityWarning}, }, snapshot.ActionItems.Items) } - -func TestDashboardHandlerGetEnvironmentsOverviewReturnsAggregateSummary(t *testing.T) { - db, settingsSvc := setupDashboardHandlerTestDB(t) - - require.NoError(t, db.WithContext(context.Background()).Create(&models.Environment{ - BaseModel: models.BaseModel{ID: "0", CreatedAt: time.Now()}, - Name: "Local Docker", - ApiUrl: "http://local.test", - Status: string(models.EnvironmentStatusOffline), - Enabled: true, - }).Error) - - handler := &DashboardHandler{ - dashboardService: services.NewDashboardService( - db, - nil, - nil, - nil, - nil, - settingsSvc, - nil, - services.NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil), - services.NewVersionService(nil, true, "1.2.3", "abcdef1234567890", nil, nil), - ), - } - - output, err := handler.GetEnvironmentsOverview(context.Background(), &GetDashboardEnvironmentsOverviewInput{}) - require.NoError(t, err) - require.NotNil(t, output) - require.True(t, output.Body.Success) - require.Equal(t, 1, output.Body.Data.Summary.TotalEnvironments) - require.Len(t, output.Body.Data.Environments, 1) - require.Equal(t, "0", output.Body.Data.Environments[0].Environment.ID) - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateSkipped, output.Body.Data.Environments[0].SnapshotState) - require.Nil(t, output.Body.Data.Environments[0].VersionInfo) -} diff --git a/backend/internal/services/dashboard_service.go b/backend/internal/services/dashboard_service.go index 653ef8b09b..81cba36c84 100644 --- a/backend/internal/services/dashboard_service.go +++ b/backend/internal/services/dashboard_service.go @@ -3,20 +3,16 @@ package services import ( "context" "fmt" - "log/slog" - "net/http" "sort" "time" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" - "github.com/getarcaneapp/arcane/backend/pkg/libarcane" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" - environmenttypes "github.com/getarcaneapp/arcane/types/environment" imagetypes "github.com/getarcaneapp/arcane/types/image" versiontypes "github.com/getarcaneapp/arcane/types/version" dockercontainer "github.com/moby/moby/api/types/container" @@ -25,8 +21,6 @@ import ( const defaultDashboardAPIKeyExpiryWindow = 14 * 24 * time.Hour const dashboardSnapshotPreloadLimit = 50 -const defaultAggregateDashboardConcurrency = 4 -const defaultAggregateDashboardTimeout = 20 * time.Second const localEnvironmentID = "0" type DashboardService struct { @@ -142,6 +136,11 @@ func (s *DashboardService) GetSnapshot(ctx context.Context, options DashboardAct return nil, err } + var versionInfo *versiontypes.Info + if s.versionService != nil { + versionInfo = s.versionService.GetAppVersionInfo(ctx) + } + return &dashboardtypes.Snapshot{ Containers: dashboardtypes.SnapshotContainers{ Data: containerPage, @@ -155,150 +154,10 @@ func (s *DashboardService) GetSnapshot(ctx context.Context, options DashboardAct ImageUsageCounts: imageUsageCounts, ActionItems: *actionItems, Settings: dashboardtypes.SnapshotSettings{}, + VersionInfo: versionInfo, }, nil } -func (s *DashboardService) GetActionItems(ctx context.Context, options DashboardActionItemsOptions) (*dashboardtypes.ActionItems, error) { - if options.DebugAllGood { - return &dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}}, nil - } - - var ( - stoppedContainers int - pendingResourceUpdates int - actionableVulnerabilities int - expiringAPIKeys int - ) - - g, groupCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - count, err := s.getStoppedContainersCountInternal(groupCtx) - if err != nil { - return err - } - stoppedContainers = count - return nil - }) - - g.Go(func() error { - count, err := s.getPendingResourceUpdatesCountInternal(groupCtx) - if err != nil { - return err - } - pendingResourceUpdates = count - return nil - }) - - g.Go(func() error { - count, err := s.getActionableVulnerabilitiesCountInternal(groupCtx) - if err != nil { - return err - } - actionableVulnerabilities = count - return nil - }) - - g.Go(func() error { - count, err := s.getExpiringAPIKeysCountInternal(groupCtx) - if err != nil { - return err - } - expiringAPIKeys = count - return nil - }) - - if err := g.Wait(); err != nil { - return nil, err - } - - actionItems := make([]dashboardtypes.ActionItem, 0, 4) - - if stoppedContainers > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindStoppedContainers, - Count: stoppedContainers, - Severity: dashboardtypes.ActionItemSeverityWarning, - }) - } - - if pendingResourceUpdates > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindImageUpdates, - Count: pendingResourceUpdates, - Severity: dashboardtypes.ActionItemSeverityWarning, - }) - } - - if actionableVulnerabilities > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindActionableVulnerabilities, - Count: actionableVulnerabilities, - Severity: dashboardtypes.ActionItemSeverityCritical, - }) - } - - if expiringAPIKeys > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindExpiringKeys, - Count: expiringAPIKeys, - Severity: dashboardtypes.ActionItemSeverityWarning, - }) - } - - return &dashboardtypes.ActionItems{Items: actionItems}, nil -} - -func (s *DashboardService) GetEnvironmentsOverview( - ctx context.Context, - options DashboardActionItemsOptions, -) (*dashboardtypes.EnvironmentsOverview, error) { - if s.environmentService == nil { - return nil, fmt.Errorf("environment service not available") - } - - environments, err := s.environmentService.ListVisibleEnvironments(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list environments: %w", err) - } - - overview := &dashboardtypes.EnvironmentsOverview{ - Environments: make([]dashboardtypes.EnvironmentOverview, len(environments)), - } - - g, groupCtx := errgroup.WithContext(ctx) - g.SetLimit(defaultAggregateDashboardConcurrency) - - for i := range environments { - index := i - env := environments[i] - - g.Go(func() error { - overview.Environments[index] = s.buildEnvironmentOverviewInternal(groupCtx, env, options) - return nil - }) - } - - if err := g.Wait(); err != nil { - return nil, fmt.Errorf("failed to build environments overview: %w", err) - } - - sort.SliceStable(overview.Environments, func(i, j int) bool { - left := overview.Environments[i].Environment - right := overview.Environments[j].Environment - if left.ID == localEnvironmentID { - return true - } - if right.ID == localEnvironmentID { - return false - } - return left.Name < right.Name - }) - - overview.Summary = summarizeEnvironmentOverviewInternal(overview.Environments) - return overview, nil -} - func (s *DashboardService) buildActionItemsForSnapshotInternal( ctx context.Context, options DashboardActionItemsOptions, @@ -401,209 +260,6 @@ func buildDashboardActionItemsInternal( return &dashboardtypes.ActionItems{Items: actionItems} } -func (s *DashboardService) buildEnvironmentOverviewInternal( - ctx context.Context, - env environmenttypes.Environment, - options DashboardActionItemsOptions, -) dashboardtypes.EnvironmentOverview { - overview := dashboardtypes.EnvironmentOverview{ - Environment: env, - Containers: containertypes.StatusCounts{}, - ImageUsageCounts: imagetypes.UsageCounts{}, - ActionItems: dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}}, - Settings: dashboardtypes.SnapshotSettings{}, - SnapshotState: dashboardtypes.EnvironmentSnapshotStateSkipped, - } - - if !env.Enabled || !shouldFetchEnvironmentSnapshotInternal(env) { - return overview - } - - var ( - snapshot *dashboardtypes.Snapshot - snapshotErr error - versionInfo *versiontypes.Info - ) - - g, groupCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - snapshot, snapshotErr = s.getSnapshotForEnvironmentInternal(groupCtx, env, options) - return nil - }) - - g.Go(func() error { - versionInfo = s.getVersionInfoForEnvironmentInternal(groupCtx, env) - return nil - }) - - _ = g.Wait() - - if versionInfo != nil { - overview.VersionInfo = versionInfo - } - - if snapshotErr != nil { - overview.SnapshotState = dashboardtypes.EnvironmentSnapshotStateError - overview.SnapshotError = new(snapshotErr.Error()) - return overview - } - - overview.Containers = snapshot.Containers.Counts - overview.ImageUsageCounts = snapshot.ImageUsageCounts - overview.ActionItems = snapshot.ActionItems - overview.Settings = snapshot.Settings - overview.SnapshotState = dashboardtypes.EnvironmentSnapshotStateReady - - return overview -} - -func shouldFetchEnvironmentSnapshotInternal(env environmenttypes.Environment) bool { - switch env.Status { - case string(models.EnvironmentStatusOnline), string(models.EnvironmentStatusStandby): - return true - default: - return false - } -} - -func (s *DashboardService) getSnapshotForEnvironmentInternal( - ctx context.Context, - env environmenttypes.Environment, - options DashboardActionItemsOptions, -) (*dashboardtypes.Snapshot, error) { - reqCtx, cancel := context.WithTimeout(ctx, defaultAggregateDashboardTimeout) - defer cancel() - - if env.ID == localEnvironmentID { - return s.GetSnapshot(reqCtx, options) - } - - var response base.ApiResponse[dashboardtypes.Snapshot] - if err := s.environmentService.ProxyJSONRequest( - reqCtx, - env.ID, - http.MethodGet, - buildEnvironmentDashboardProxyPathInternal(options), - nil, - &response, - ); err != nil { - return nil, fmt.Errorf("failed to proxy dashboard snapshot: %w", err) - } - if !response.Success { - return nil, fmt.Errorf("dashboard snapshot request was not successful") - } - - return &response.Data, nil -} - -func buildEnvironmentDashboardProxyPathInternal(options DashboardActionItemsOptions) string { - if options.DebugAllGood { - return fmt.Sprintf("/api/environments/%s/dashboard?debugAllGood=true", localEnvironmentID) - } - - return fmt.Sprintf("/api/environments/%s/dashboard", localEnvironmentID) -} - -func (s *DashboardService) getVersionInfoForEnvironmentInternal( - ctx context.Context, - env environmenttypes.Environment, -) *versiontypes.Info { - if s.versionService == nil { - return nil - } - - reqCtx, cancel := context.WithTimeout(ctx, defaultAggregateDashboardTimeout) - defer cancel() - - if env.ID == localEnvironmentID { - return s.versionService.GetAppVersionInfo(reqCtx) - } - - if s.environmentService == nil { - return nil - } - - var versionInfo versiontypes.Info - if err := s.environmentService.ProxyJSONRequest( - reqCtx, - env.ID, - http.MethodGet, - "/api/app-version", - nil, - &versionInfo, - ); err != nil { - slog.DebugContext(reqCtx, "Failed to fetch environment version info for dashboard", "environment_id", env.ID, "error", err) - return nil - } - - return &versionInfo -} - -func (s *DashboardService) getStoppedContainersCountInternal(ctx context.Context) (int, error) { - if s.dockerService == nil { - return 0, nil - } - - containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) - if err != nil { - return 0, fmt.Errorf("failed to load container counts: %w", err) - } - - stoppedCount := 0 - for _, container := range containers { - if libarcane.IsInternalContainer(container.Labels) { - continue - } - - if container.State != "running" { - stoppedCount++ - } - } - - return stoppedCount, nil -} - -func summarizeEnvironmentOverviewInternal(items []dashboardtypes.EnvironmentOverview) dashboardtypes.EnvironmentsSummary { - summary := dashboardtypes.EnvironmentsSummary{} - - for _, item := range items { - summary.TotalEnvironments++ - - if !item.Environment.Enabled { - summary.DisabledEnvironments++ - } else { - switch item.Environment.Status { - case string(models.EnvironmentStatusOnline): - summary.OnlineEnvironments++ - case string(models.EnvironmentStatusStandby): - summary.StandbyEnvironments++ - case string(models.EnvironmentStatusPending): - summary.PendingEnvironments++ - case string(models.EnvironmentStatusError): - summary.ErrorEnvironments++ - default: - summary.OfflineEnvironments++ - } - } - - summary.Containers.RunningContainers += item.Containers.RunningContainers - summary.Containers.StoppedContainers += item.Containers.StoppedContainers - summary.Containers.TotalContainers += item.Containers.TotalContainers - - summary.ImageUsageCounts.Inuse += item.ImageUsageCounts.Inuse - summary.ImageUsageCounts.Unused += item.ImageUsageCounts.Unused - summary.ImageUsageCounts.Total += item.ImageUsageCounts.Total - summary.ImageUsageCounts.TotalSize += item.ImageUsageCounts.TotalSize - - if len(item.ActionItems.Items) > 0 { - summary.EnvironmentsWithActionItems++ - } - } - - return summary -} - func (s *DashboardService) getPendingResourceUpdatesCountInternal(ctx context.Context) (int, error) { if s.db == nil || s.dockerService == nil { return 0, nil diff --git a/backend/internal/services/dashboard_service_test.go b/backend/internal/services/dashboard_service_test.go index d78720b086..47563991d3 100644 --- a/backend/internal/services/dashboard_service_test.go +++ b/backend/internal/services/dashboard_service_test.go @@ -14,16 +14,11 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" - "github.com/getarcaneapp/arcane/types/base" - containertypes "github.com/getarcaneapp/arcane/types/container" dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" - imagetypes "github.com/getarcaneapp/arcane/types/image" - versiontypes "github.com/getarcaneapp/arcane/types/version" glsqlite "github.com/glebarez/sqlite" dockercontainer "github.com/moby/moby/api/types/container" dockerimage "github.com/moby/moby/api/types/image" "github.com/moby/moby/client" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) @@ -52,11 +47,6 @@ func createDashboardTestImageUpdateRecord(t *testing.T, db *database.DB, record require.NoError(t, db.WithContext(context.Background()).Create(&record).Error) } -func createDashboardTestEnvironment(t *testing.T, db *database.DB, env models.Environment) { - t.Helper() - require.NoError(t, db.WithContext(context.Background()).Create(&env).Error) -} - func newDashboardTestDockerService( t *testing.T, settingsSvc *SettingsService, @@ -94,74 +84,6 @@ func newDashboardTestDockerService( } } -func newDashboardTestVersionServiceInternal() *VersionService { - return NewVersionService(nil, true, "1.2.3", "abcdef1234567890", nil, nil) -} - -func TestDashboardService_GetActionItems_IncludesExpiringAPIKeys(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, nil, nil) - - now := time.Now() - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "expiring-soon", - KeyHash: "hash-soon", - KeyPrefix: "arc_test_s", - UserID: new("user-1"), - ExpiresAt: new(now.Add(24 * time.Hour)), - }) - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "already-expired", - KeyHash: "hash-expired", - KeyPrefix: "arc_test_e", - UserID: new("user-1"), - ExpiresAt: new(now.Add(-24 * time.Hour)), - }) - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "future", - KeyHash: "hash-future", - KeyPrefix: "arc_test_f", - UserID: new("user-1"), - ExpiresAt: new(now.Add(45 * 24 * time.Hour)), - }) - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "never-expires", - KeyHash: "hash-never", - KeyPrefix: "arc_test_n", - UserID: new("user-1"), - }) - - actionItems, err := svc.GetActionItems(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, actionItems) - require.Len(t, actionItems.Items, 1) - - item := actionItems.Items[0] - require.Equal(t, dashboardtypes.ActionItemKindExpiringKeys, item.Kind) - require.Equal(t, 2, item.Count) - require.Equal(t, dashboardtypes.ActionItemSeverityWarning, item.Severity) -} - -func TestDashboardService_GetActionItems_DebugAllGoodReturnsNoItems(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, nil, nil) - - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "expiring-soon", - KeyHash: "hash-soon", - KeyPrefix: "arc_test_d", - UserID: new("user-1"), - ExpiresAt: new(time.Now().Add(2 * time.Hour)), - }) - - actionItems, err := svc.GetActionItems(context.Background(), DashboardActionItemsOptions{ - DebugAllGood: true, - }) - require.NoError(t, err) - require.NotNil(t, actionItems) - require.Empty(t, actionItems.Items) -} - func TestDashboardService_GetSnapshot_ReturnsDashboardSnapshot(t *testing.T) { db, settingsSvc := setupDashboardServiceTestDB(t) @@ -296,289 +218,3 @@ func TestDashboardService_GetSnapshot_DebugAllGoodOnlyClearsActionItems(t *testi require.Equal(t, 1, snapshot.ImageUsageCounts.Inuse) require.Empty(t, snapshot.ActionItems.Items) } - -func TestDashboardService_GetEnvironmentsOverview_ReturnsLocalAndRemoteSummaries(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - - containers := []dockercontainer.Summary{ - { - ID: "local-running", - Names: []string{"/local-running"}, - Image: "repo/local:stable", - ImageID: "sha256:local-image", - Created: 1700000000, - State: "running", - Status: "Up 1 hour", - Labels: map[string]string{}, - }, - } - images := []dockerimage.Summary{ - {ID: "sha256:local-image", RepoTags: []string{"repo/local:stable"}, Created: 1710000000, Size: 150}, - } - - remoteSnapshot := base.ApiResponse[dashboardtypes.Snapshot]{ - Success: true, - Data: dashboardtypes.Snapshot{ - Containers: dashboardtypes.SnapshotContainers{ - Counts: containertypes.StatusCounts{ - RunningContainers: 2, - StoppedContainers: 1, - TotalContainers: 3, - }, - }, - ImageUsageCounts: imagetypes.UsageCounts{ - Inuse: 2, - Unused: 3, - Total: 5, - TotalSize: 900, - }, - ActionItems: dashboardtypes.ActionItems{ - Items: []dashboardtypes.ActionItem{ - {Kind: dashboardtypes.ActionItemKindStoppedContainers, Count: 1, Severity: dashboardtypes.ActionItemSeverityWarning}, - }, - }, - }, - } - - remoteVersion := versiontypes.Info{ - CurrentVersion: "v2.4.0", - DisplayVersion: "v2.4.0", - Revision: "1234567890abcdef", - ShortRevision: "12345678", - GoVersion: "go1.24.0", - IsSemverVersion: true, - UpdateAvailable: true, - NewestVersion: "v2.5.0", - ReleaseURL: "https://github.com/getarcaneapp/arcane/releases/tag/v2.5.0", - } - - remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/environments/0/dashboard": - require.NoError(t, json.NewEncoder(w).Encode(remoteSnapshot)) - case "/api/app-version": - require.NoError(t, json.NewEncoder(w).Encode(remoteVersion)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(remoteServer.Close) - - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "0", CreatedAt: time.Now()}, - Name: "Local Docker", - ApiUrl: "http://local.test", - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-remote", CreatedAt: time.Now()}, - Name: "Remote Alpha", - ApiUrl: remoteServer.URL, - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - - dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images) - envSvc := NewEnvironmentService(db, remoteServer.Client(), nil, nil, settingsSvc, nil) - svc := NewDashboardService(db, dockerSvc, nil, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal()) - - overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, overview) - require.Len(t, overview.Environments, 2) - - require.Equal(t, 2, overview.Summary.TotalEnvironments) - require.Equal(t, 2, overview.Summary.OnlineEnvironments) - require.Equal(t, 4, overview.Summary.Containers.TotalContainers) - require.Equal(t, 6, overview.Summary.ImageUsageCounts.Total) - require.Equal(t, 1, overview.Summary.EnvironmentsWithActionItems) - - require.Equal(t, "0", overview.Environments[0].Environment.ID) - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[0].SnapshotState) - require.Equal(t, 1, overview.Environments[0].Containers.TotalContainers) - require.NotNil(t, overview.Environments[0].VersionInfo) - require.Equal(t, "v1.2.3", overview.Environments[0].VersionInfo.CurrentVersion) - - require.Equal(t, "env-remote", overview.Environments[1].Environment.ID) - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[1].SnapshotState) - require.Equal(t, 3, overview.Environments[1].Containers.TotalContainers) - require.Len(t, overview.Environments[1].ActionItems.Items, 1) - require.NotNil(t, overview.Environments[1].VersionInfo) - require.Equal(t, "v2.5.0", overview.Environments[1].VersionInfo.NewestVersion) -} - -func TestDashboardService_GetEnvironmentsOverview_HandlesRemoteSnapshotFailure(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-offline", CreatedAt: time.Now()}, - Name: "Offline Env", - ApiUrl: "http://offline.test", - Status: string(models.EnvironmentStatusOffline), - Enabled: true, - }) - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-error", CreatedAt: time.Now()}, - Name: "Broken Env", - ApiUrl: "http://127.0.0.1:1", - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - - envSvc := NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal()) - - overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, overview) - require.Len(t, overview.Environments, 2) - - byID := make(map[string]dashboardtypes.EnvironmentOverview, len(overview.Environments)) - for _, item := range overview.Environments { - byID[item.Environment.ID] = item - } - - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateSkipped, byID["env-offline"].SnapshotState) - require.Nil(t, byID["env-offline"].SnapshotError) - require.Nil(t, byID["env-offline"].VersionInfo) - - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateError, byID["env-error"].SnapshotState) - require.NotNil(t, byID["env-error"].SnapshotError) - require.Contains(t, *byID["env-error"].SnapshotError, "failed to proxy dashboard snapshot") - require.Nil(t, byID["env-error"].VersionInfo) -} - -func TestDashboardService_GetEnvironmentsOverview_OmitsVersionInfoWhenFetchFails(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - - remoteSnapshot := base.ApiResponse[dashboardtypes.Snapshot]{ - Success: true, - Data: dashboardtypes.Snapshot{ - Containers: dashboardtypes.SnapshotContainers{ - Counts: containertypes.StatusCounts{ - RunningContainers: 1, - StoppedContainers: 0, - TotalContainers: 1, - }, - }, - ImageUsageCounts: imagetypes.UsageCounts{ - Inuse: 1, - Unused: 0, - Total: 1, - TotalSize: 128, - }, - ActionItems: dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}}, - }, - } - - remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/environments/0/dashboard": - require.NoError(t, json.NewEncoder(w).Encode(remoteSnapshot)) - case "/api/app-version": - http.Error(w, "version unavailable", http.StatusInternalServerError) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(remoteServer.Close) - - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-remote", CreatedAt: time.Now()}, - Name: "Remote Alpha", - ApiUrl: remoteServer.URL, - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - - envSvc := NewEnvironmentService(db, remoteServer.Client(), nil, nil, settingsSvc, nil) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal()) - - overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, overview) - require.Len(t, overview.Environments, 1) - - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[0].SnapshotState) - require.Equal(t, 1, overview.Environments[0].Containers.TotalContainers) - require.Nil(t, overview.Environments[0].VersionInfo) -} - -func TestDashboardService_GetActionItems_CountsAffectedResources(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - ctx := context.Background() - - containers := []dockercontainer.Summary{ - { - ID: "container-updated", - Names: []string{"/updated-app"}, - Image: "repo/app:latest", - ImageID: "sha256:image-a", - Created: 1700000000, - State: "running", - Status: "Up 2 hours", - Labels: map[string]string{}, - }, - { - ID: "compose-updated", - Names: []string{"/compose-app"}, - Image: "repo/compose:latest", - ImageID: "sha256:image-compose", - Created: 1700000001, - State: "running", - Status: "Up 2 hours", - Labels: map[string]string{ - "com.docker.compose.project": "compose-demo", - }, - }, - } - images := []dockerimage.Summary{ - {ID: "sha256:image-a", RepoTags: []string{"repo/app:latest"}, Created: 1710000000, Size: 100}, - {ID: "sha256:image-unused", RepoTags: []string{"repo/unused:latest"}, Created: 1710000001, Size: 50}, - } - - createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ - ID: "sha256:image-a", - Repository: "docker.io/repo/app", - Tag: "latest", - HasUpdate: true, - }) - createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ - ID: "sha256:image-unused", - Repository: "docker.io/repo/unused", - Tag: "latest", - HasUpdate: true, - }) - createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ - ID: "sha256:image-compose", - Repository: "docker.io/repo/compose", - Tag: "latest", - HasUpdate: true, - }) - - projectsDir := t.TempDir() - t.Setenv("PROJECTS_DIRECTORY", projectsDir) - require.NoError(t, settingsSvc.SetStringSetting(ctx, "projectsDirectory", projectsDir)) - projectPath := createComposeProjectDir(t, projectsDir, "project-with-update") - require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: repo/app:latest\n"), 0o644)) - require.NoError(t, db.WithContext(ctx).Create(&models.Project{ - BaseModel: models.BaseModel{ID: "project-with-update"}, - Name: "project-with-update", - DirName: ptr("project-with-update"), - Path: projectPath, - Status: models.ProjectStatusStopped, - }).Error) - - dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images) - projectSvc := NewProjectService(db, settingsSvc, nil, &ImageService{db: db}, nil, nil, config.Load()) - svc := NewDashboardService(db, dockerSvc, nil, projectSvc, nil, settingsSvc, nil, nil, nil) - - actionItems, err := svc.GetActionItems(ctx, DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, actionItems) - - require.Len(t, actionItems.Items, 1) - assert.Equal(t, dashboardtypes.ActionItemKindImageUpdates, actionItems.Items[0].Kind) - assert.Equal(t, 2, actionItems.Items[0].Count) -} diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index 6f603d0355..4e55c50d59 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -144,7 +144,7 @@ type ArcaneApiEndpoints struct { TemplateFetchEndpoint string // Dashboard - DashboardActionItemsEndpoint string + DashboardEndpoint string // Assets AppImagesFaviconEndpoint string @@ -313,7 +313,7 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut TemplateFetchEndpoint: "/api/templates/fetch", // Dashboard - DashboardActionItemsEndpoint: "/api/environments/%s/dashboard/action-items", + DashboardEndpoint: "/api/environments/%s/dashboard", // Assets AppImagesFaviconEndpoint: "/api/app-images/favicon", @@ -657,8 +657,8 @@ func (e ArcaneApiEndpoints) TemplateDownload(id string) string { func (e ArcaneApiEndpoints) TemplateFetch() string { return e.TemplateFetchEndpoint } // Dashboard endpoints -func (e ArcaneApiEndpoints) DashboardActionItems(envID string) string { - return fmt.Sprintf(e.DashboardActionItemsEndpoint, envID) +func (e ArcaneApiEndpoints) Dashboard(envID string) string { + return fmt.Sprintf(e.DashboardEndpoint, envID) } // GitOps Sync endpoints diff --git a/cli/pkg/alerts/cmd.go b/cli/pkg/alerts/cmd.go deleted file mode 100644 index f76876be9f..0000000000 --- a/cli/pkg/alerts/cmd.go +++ /dev/null @@ -1,101 +0,0 @@ -package alerts - -import ( - "fmt" - "net/url" - "strings" - - "github.com/getarcaneapp/arcane/cli/internal/cmdutil" - "github.com/getarcaneapp/arcane/cli/internal/output" - "github.com/getarcaneapp/arcane/cli/internal/types" - "github.com/getarcaneapp/arcane/types/base" - dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" - "github.com/spf13/cobra" -) - -var debugAllGood bool - -// AlertsCmd shows dashboard alerts/action items. -var AlertsCmd = &cobra.Command{ - Use: "alerts", - Aliases: []string{"alert", "actionitems"}, - Short: "Show dashboard alerts", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - c, err := cmdutil.ClientFromCommand(cmd) - if err != nil { - return err - } - - path := types.Endpoints.DashboardActionItems(c.EnvID()) - if debugAllGood { - parsed, err := url.Parse(path) - if err != nil { - return fmt.Errorf("failed to parse endpoint path: %w", err) - } - q := parsed.Query() - q.Set("debugAllGood", "true") - parsed.RawQuery = q.Encode() - path = parsed.String() - } - - resp, err := c.Get(cmd.Context(), path) - if err != nil { - return fmt.Errorf("failed to get dashboard alerts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[dashboardtypes.ActionItems] - if err := cmdutil.DecodeJSON(resp, &result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if cmdutil.JSONOutputEnabled(cmd) { - return cmdutil.PrintJSON(result.Data) - } - - items := result.Data.Items - if len(items) == 0 { - output.Success("No alerts. Environment is good.") - return nil - } - - output.Header("Dashboard Alerts") - headers := []string{"KIND", "COUNT", "SEVERITY"} - rows := make([][]string, 0, len(items)) - for _, item := range items { - rows = append(rows, []string{ - formatActionItemKind(item.Kind), - fmt.Sprintf("%d", item.Count), - strings.ToUpper(string(item.Severity)), - }) - } - output.Table(headers, rows) - output.Info("Total: %d alert(s)", len(items)) - return nil - }, -} - -func formatActionItemKind(kind dashboardtypes.ActionItemKind) string { - switch kind { - case dashboardtypes.ActionItemKindStoppedContainers: - return "Stopped Containers" - case dashboardtypes.ActionItemKindImageUpdates: - return "Image Updates" - case dashboardtypes.ActionItemKindActionableVulnerabilities: - return "Actionable Vulnerabilities" - case dashboardtypes.ActionItemKindExpiringKeys: - return "Expiring Keys" - default: - value := strings.TrimSpace(string(kind)) - if value == "" { - return "-" - } - return strings.ReplaceAll(value, "_", " ") - } -} - -func init() { - AlertsCmd.Flags().Bool("json", false, "Output in JSON format") - AlertsCmd.Flags().BoolVar(&debugAllGood, "debug-all-good", false, "Force no alerts (debug mode)") -} diff --git a/cli/pkg/root.go b/cli/pkg/root.go index 2e1f1a4933..97c4e71e5a 100644 --- a/cli/pkg/root.go +++ b/cli/pkg/root.go @@ -21,7 +21,6 @@ // # Command Groups // // - admin: Administration & platform management -// - alerts: Show dashboard alerts // - auth: Authentication operations // - config: Manage CLI configuration // - containers: Manage containers @@ -48,7 +47,6 @@ import ( "github.com/getarcaneapp/arcane/cli/internal/runstate" runtimectx "github.com/getarcaneapp/arcane/cli/internal/runtime" "github.com/getarcaneapp/arcane/cli/pkg/admin" - "github.com/getarcaneapp/arcane/cli/pkg/alerts" "github.com/getarcaneapp/arcane/cli/pkg/auth" "github.com/getarcaneapp/arcane/cli/pkg/completion" configClient "github.com/getarcaneapp/arcane/cli/pkg/config" @@ -256,7 +254,6 @@ func init() { rootCmd.AddCommand(generate.GenerateCmd) rootCmd.AddCommand(version.VersionCmd) rootCmd.AddCommand(auth.AuthCmd) - rootCmd.AddCommand(alerts.AlertsCmd) rootCmd.AddCommand(containers.ContainersCmd) rootCmd.AddCommand(images.ImagesCmd) rootCmd.AddCommand(volumes.VolumesCmd) diff --git a/docs/rbac.md b/docs/rbac.md deleted file mode 100644 index 17711c8626..0000000000 --- a/docs/rbac.md +++ /dev/null @@ -1,180 +0,0 @@ -# Role-Based Access Control - -Arcane uses a role-based access control (RBAC) system to govern what users and API keys can do. Every action — starting a container, deploying a project, editing settings — is gated by a specific permission. Permissions are bundled into roles, and roles are assigned to users (optionally scoped to a specific environment). - -If you're upgrading from an earlier version of Arcane, see the [Upgrade & Migration](#upgrade--migration) section below. - -## The mental model - -There are four moving parts: - -- **Permissions** are fine-grained strings of the form `:` — for example `containers:start`, `projects:deploy`, `users:create`. You don't pick these one-by-one; you bundle them into roles. -- **Roles** are named permission sets. Arcane ships with four built-in roles (Admin, Editor, Deployer, Viewer) and lets admins create custom ones. -- **Role assignments** bind a user to a role, optionally scoped to a single environment. A user can hold many assignments — for example *Admin globally*, or *Editor on production + Viewer on staging*. -- **OIDC mappings** translate SSO group claims into role assignments automatically on every login. - -Permissions split into two scopes: - -- **Org-level permissions** apply across the whole instance. Settings, users, registries, templates, environments management — these are all org-level. To get an org-level permission you need a **globally-scoped** role assignment. -- **Env-scoped permissions** apply to one environment at a time. Containers, projects, images, volumes, networks, swarm, GitOps — these are all env-scoped. You can hold a different role on each environment. - -A user with `Editor on production` can manage Docker resources on that environment, but cannot change settings (org-level), and has no access at all on other environments. - -## Built-in roles - -The six built-in roles cover the common access patterns. They are immutable — you can't edit or delete them, but you can clone one as a starting point for a custom role. - -| Role | Intended use | What it can do | -| -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Admin** | Full instance operators | Everything. Manages users, roles, settings, registries, and every Docker resource on every environment. | -| **Editor** | Day-to-day Docker management | Read+write on containers, projects, images, volumes, networks, swarm resources, GitOps syncs, webhooks, jobs, notifications, vulnerabilities. Read-only on settings/users. | -| **No-Shell Editor** | Editor without `exec` access | Same as Editor, but cannot open an interactive shell inside a running container. | -| **Deployer** | CI/CD service accounts and on-call responders | Deploy projects, restart/start/stop/redeploy containers, sync GitOps, pull images. Cannot create or delete resources, cannot manage settings or users. | -| **Monitor** | Observability and on-call read access | Read Docker resources, view container/project/service logs, dashboards, events. No mutations, no exec, no settings access. | -| **Viewer** | Auditors and read-only stakeholders | Read-only access to every Docker resource and to most org-level pages. Cannot view logs, start, stop, change, or delete anything. | - -You must have at least one user holding **Admin** globally at all times. Arcane will refuse role-assignment edits, OIDC mapping changes, and user deletions that would leave the system with zero global admins. - -## Custom roles - -> **Admin-only:** creating, editing, and deleting roles — and assigning roles to users — is reserved for global admins. The same applies to OIDC group mappings. These actions are intentionally not delegatable as fine-grained permissions; the matching API routes return 403 to non-admins. - -Admins can create custom roles to fit narrower job functions — for example, "Database Operator" that can manage only volumes and backups, or "Security Reviewer" that can read vulnerability scans but trigger nothing. - -To create a custom role: - -1. Open **Settings → Roles**. -2. Click **Create role**. -3. Name the role, optionally add a description. -4. In the permission picker on the right, expand each resource group and check the actions this role should grant. A "select all" checkbox at the top of each group is handy for full-read or full-write roles. -5. Save. - -Custom roles can be edited and deleted at any time. Built-in roles show a badge and the editor is read-only — use the **Clone as custom role** button to start a new role pre-populated with a built-in's permissions. - -When you delete a custom role, every assignment of it is also removed. The global-admin guard still applies — if removing the role would orphan the last global Admin, the deletion is refused. - -## Assigning roles to users - -From **Settings → Users**, open a user. The **Role assignments** section shows every role they hold, with a scope label (Global or a specific environment name). You can: - -- Add a row to grant a new role on an environment (or globally). -- Remove a row to revoke that grant. -- Use the **Global** scope to grant org-level permissions. - -A user with no assignments at all can authenticate but cannot read or do anything — they'll land on a "no access" screen until an admin grants them a role. - -### OIDC users - -If a user authenticated via OIDC matches an OIDC role mapping, their assignments come from the mapping table and are managed there. The user editor will be read-only for those assignments and link to **Settings → OIDC Mappings**. Manual assignments on OIDC users (for groups that have no mapping) still work and are preserved across logins. - -## OIDC group mappings - -For SSO-only deployments, role assignment can be driven entirely from your IdP. Map an OIDC group claim value to a role and an optional environment scope; on every login Arcane reads the user's group claim and re-syncs their `source=oidc` assignments to match. - -### Setup - -1. Confirm your IdP issues a groups claim. By default Arcane reads it from the `groups` claim — you can change this in **Settings → Settings** under **OIDC Groups Claim**. Common alternatives are `realm_access.roles` (Keycloak), `memberOf` (Azure AD), or a custom claim path. -2. Open **Settings → OIDC Mappings**. -3. Click **Add mapping** and fill in: - - **Claim value**: the exact string that appears in the user's groups claim (e.g. `docker-admins`) - - **Role**: the role to grant when this claim is present - - **Environment scope**: `Global` for org-level role, or a specific environment -4. Save. - -A user who is a member of multiple mapped groups receives the **union** of the matching assignments. A user demoted in the IdP loses their OIDC-sourced assignments on next login; their manual assignments (if any) remain. - -The legacy `OidcAdminClaim` / `OidcAdminValue` settings continue to work as an implicit "matching claim grants Admin globally" mapping. New deployments should configure mappings explicitly and leave those settings empty. - -### Declaring mappings via env (GitOps / IaC) - -For deployments that prefer to keep everything in source control, mappings can be declared via the `OIDC_ROLE_MAPPINGS` env var. The value is a JSON array of `{claimValue, roleId, environmentId?}` entries; reconciliation runs at every boot and is fully declarative — adding an entry creates it, removing one deletes it, editing one updates it. Manual mappings created in the UI are never touched. - -``` -OIDC_ROLE_MAPPINGS='[ - {"claimValue": "docker-admins", "roleId": "role_admin"}, - {"claimValue": "devops", "roleId": "role_editor", "environmentId": "env-prod"} -]' -``` - -Env-managed rows are flagged with a `ENV` badge in the UI and are read-only — the Edit/Delete actions are disabled, and the API returns 409 if you try to mutate them. To change one, edit the env var and restart. Set `OIDC_ROLE_MAPPINGS='[]'` to wipe every env-managed row. The matching `OIDC_ROLE_MAPPINGS_FILE` env var works for Docker secret files. - -## API keys - -Every API key now carries its own permission set, independent of the owning user. This means you can issue narrow-scoped keys for CI/CD ("can deploy projects on prod, nothing else") without granting the owning user the same scope. - -### Creating a scoped API key - -1. Open **Settings → API Keys** and click **Create API key**. -2. Fill in name, description, optional expiration. -3. In the **Permissions** section, check the permissions this key should hold. Use environment scope just like role assignments. -4. Save and copy the key value — it is shown only once. - -You cannot grant a key permissions you don't have yourself. Server-side validation rejects creating a key whose permission set exceeds the creator's effective permission set. - -### What happens to existing keys on upgrade - -Existing API keys created before this release inherit a snapshot of their owner's current effective permissions at upgrade time, scoped per the key's environment binding. If an owner's permissions change later, their existing keys are *not* automatically re-scoped — admins should review and re-issue keys after large permission changes. - -## The permission catalog - -Permissions follow `:`. You don't usually pick them individually — the role editor groups them by resource — but here's the catalog for reference. - -### Org-level (require global-scope role) - -- **users**: `list`, `read`, `create`, `update`, `delete` -- **roles**: `list`, `read` *(creating, editing, deleting, and assigning roles are admin-only — not delegatable)* -- **apikeys**: `list`, `read`, `create`, `update`, `delete` -- **settings**: `read`, `write` -- **environments**: `list`, `read`, `create`, `update`, `delete`, `pair`, `sync` -- **registries** (container registries): `list`, `read`, `create`, `update`, `delete`, `test` -- **templates**: `list`, `read`, `create`, `update`, `delete` -- **git-repositories**: `list`, `read`, `create`, `update`, `delete`, `test`, `sync` -- **events**: `read` -- **customize**: `manage` - -### Env-scoped (per environment) - -- **containers**: `list`, `read`, `logs`, `create`, `start`, `stop`, `restart`, `redeploy`, `delete`, `exec`, `autoupdate` -- **projects**: `list`, `read`, `logs`, `create`, `update`, `deploy`, `down`, `restart`, `delete`, `archive` -- **images**: `list`, `read`, `pull`, `push`, `build`, `prune`, `delete`, `upload` -- **volumes**: `list`, `read`, `create`, `delete`, `prune`, `browse`, `upload`, `backup` -- **networks**: `list`, `read`, `create`, `delete`, `prune` -- **swarm**: `read`, `init`, `join`, `leave`, `spec`, `nodes`, `services`, `services:logs`, `stacks`, `configs`, `secrets`, `unlock` -- **gitops**: `list`, `read`, `create`, `update`, `delete`, `sync` -- **webhooks**: `list`, `create`, `update`, `delete` -- **jobs**: `manage` -- **notifications**: `manage` -- **dashboard**: `read` -- **system**: `read`, `prune`, `upgrade` -- **image-updates**: `read`, `check` -- **vulnerabilities**: `read`, `scan`, `manage` -- **build-workspaces**: `manage` - -## Upgrade & migration - -When you upgrade from a pre-RBAC release of Arcane, the migration runs automatically the first time the new server starts: - -- Every user that held the legacy `admin` role gets a **global Admin** assignment. -- Every other user gets a single **global Viewer** assignment (read-only across everything, including the Settings area). -- Existing API keys inherit a snapshot of their owner's effective permissions. - -If for some reason the migration would leave no global admins, Arcane refuses to start and logs an error — restore from backup and investigate before retrying. - -After the upgrade you should: - -1. **Review your admins.** `Settings → Users` shows every user and their assignments at a glance. -2. **Right-size non-admins.** Defaulting everyone to global Viewer is safe but very restrictive (read-only, no logs). Promote individual users to Editor / No-Shell Editor / Deployer / Monitor on the environments they actually use. -3. **Map OIDC groups, if you use SSO.** Set the `OIDC Groups Claim` and add mappings under `Settings → OIDC Mappings`. Existing legacy `OidcAdminClaim` / `OidcAdminValue` settings keep working, but mappings give you finer control. -4. **Review API keys.** Each key now carries its own permissions; the upgrade gives them a snapshot of the owner's permissions, which is the most permissive safe default. Tighten down CI/CD keys to least privilege. - -## Troubleshooting - -**"permission denied: ..." errors after upgrade.** Find the permission string in the error, look it up in the catalog above, and check whether the caller's role grants it on the right environment. Admins can audit a user's effective permissions from `Settings → Users`. - -**A user has no access at all.** They probably have no role assignments. Open them in `Settings → Users` and add at least one assignment (typically `Viewer` on every environment they should see). - -**Last-admin guard tripped (`at least one user must retain a global Admin role assignment`).** You tried to remove, demote, or OIDC-resync away the last globally-scoped Admin. Add another global Admin first, then retry. - -**OIDC user lost their access on login.** Their group claim no longer matches any mapping. Check the IdP-side group membership and the mapping table; OIDC-sourced assignments are re-evaluated on every login. - -**API key fails with permission denied.** Keys carry their own permissions, not the owner's current permissions. If you changed the owner's role, the key still has whatever it was provisioned with. Re-issue the key with the desired scope. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 82490a8407..c031475c58 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1961,12 +1961,40 @@ "docker_info_ipv4_forwarding": "IPv4 Forwarding", "docker_info_http_proxy": "HTTP Proxy", "docker_info_https_proxy": "HTTPS Proxy", - "docker_info_no_proxy": "No Proxy", - "docker_info_bridge_ip": "Bridge IP", - "docker_info_plugins_section": "Plugins", + "docker_info_no_proxy": "No Proxy", "docker_info_plugins_section": "Plugins", "docker_info_logs_plugin": "Logs", "docker_info_security_options": "Security Options", "docker_info_runtimes": "Runtimes", + "docker_info_warnings_section": "Warnings", + "docker_info_capabilities_section": "Runtime Capabilities", + "docker_info_storage_details_section": "Storage Driver Details", + "docker_info_swarm_section": "Swarm", + "docker_info_labels_section": "Engine Labels", + "docker_info_os_version_label": "OS Version", + "docker_info_debug_label": "Debug Mode", + "docker_info_live_restore_label": "Live Restore", + "docker_info_index_server_label": "Index Server", + "docker_info_product_license_label": "Product License", + "docker_info_containerd_commit_label": "containerd", + "docker_info_runc_commit_label": "runc", + "docker_info_init_commit_label": "init", + "docker_info_memory_limit_label": "Memory Limit", + "docker_info_swap_limit_label": "Swap Limit", + "docker_info_kernel_memory_tcp_label": "Kernel Memory TCP", + "docker_info_cpu_cfs_period_label": "CPU CFS Period", + "docker_info_cpu_cfs_quota_label": "CPU CFS Quota", + "docker_info_cpu_shares_label": "CPU Shares", + "docker_info_cpu_set_label": "CPUSet", + "docker_info_pids_limit_label": "PIDs Limit", + "docker_info_oom_kill_disable_label": "OOM Kill Disable", + "docker_info_event_listeners_label": "Event Listeners", + "docker_info_address_pools_label": "Address Pools", + "docker_info_authorization_plugin": "Authorization", + "docker_info_swarm_state_label": "Node State", + "docker_info_swarm_node_id_label": "Node ID", + "docker_info_swarm_manager_label": "Is Manager", + "docker_info_swarm_managers_label": "Managers", + "docker_info_swarm_nodes_label": "Nodes", "_comment_sidebar": "=== SIDEBAR ===", "sidebar_environment_label": "Environment", "sidebar_select_environment": "Select Environment", diff --git a/frontend/src/app.css b/frontend/src/app.css index 10eb108cfd..56c7738093 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -45,7 +45,6 @@ --glass-base: var(--bg-surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.12); - --glass-noise-opacity: 0.03; } .dark { @@ -121,7 +120,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.11 0.012 264.4 / 0.12); - --glass-noise-opacity: 0.018; } :root.dark[data-app-theme='graphite'] { @@ -155,7 +153,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.38); - --glass-noise-opacity: 0.018; } :root[data-app-theme='ocean'] { @@ -189,7 +186,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.13 0.02 246.1 / 0.12); - --glass-noise-opacity: 0.02; } :root.dark[data-app-theme='ocean'] { @@ -223,7 +219,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.36); - --glass-noise-opacity: 0.02; } :root[data-app-theme='amber'] { @@ -257,7 +252,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.16 0.024 63.6 / 0.12); - --glass-noise-opacity: 0.022; } :root.dark[data-app-theme='amber'] { @@ -291,7 +285,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.02; } :root[data-app-theme='github'] { @@ -325,7 +318,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.12 0.008 248.2 / 0.1); - --glass-noise-opacity: 0.012; } :root.dark[data-app-theme='github'] { @@ -359,7 +351,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.012; } :root[data-app-theme='nord'] { @@ -393,7 +384,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.16 0.02 258.6 / 0.1); - --glass-noise-opacity: 0.018; } :root.dark[data-app-theme='nord'] { @@ -427,7 +417,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.33); - --glass-noise-opacity: 0.018; } :root[data-app-theme='everforest'] { @@ -461,7 +450,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.18 0.022 156.1 / 0.1); - --glass-noise-opacity: 0.018; } :root.dark[data-app-theme='everforest'] { @@ -495,7 +483,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.018; } :root[data-app-theme='rosepine'] { @@ -529,7 +516,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.19 0.024 313.7 / 0.1); - --glass-noise-opacity: 0.016; } :root.dark[data-app-theme='rosepine'] { @@ -563,7 +549,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.016; } /* OLED dark mode — only activates for the default application theme */ @@ -583,7 +568,6 @@ --bg-surface: oklch(0.02 0.001 285); --glass-base: oklch(0.02 0.001 285); --glass-shadow-color: oklch(0 0 0 / 0.6); - --glass-noise-opacity: 0.015; } @theme inline { @@ -703,49 +687,12 @@ } } -body.glass-enabled { - background-color: var(--background); -} - -body.glass-enabled::before { - display: none !important; -} - -@media (prefers-reduced-motion: reduce) { - body.glass-enabled::before { - animation: none; - } -} - -body::after { - content: ''; - position: fixed; - inset: 0; - z-index: -1; - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNDAiIGhlaWdodD0iMTQwIiB2aWV3Qm94PSIwIDAgMTQwIDE0MCI+PGZpbHRlciBpZD0ibiI+PGZlVHVyYnVsZW5jZSB0eXBlPSJmcmFjdGFsTm9pc2UiIGJhc2VGcmVxdWVuY3k9IjAuOSIgbnVtT2N0YXZlcz0iNCIgc3RpdGNoVGlsZXM9InN0aXRjaCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCNuKSIgb3BhY2l0eT0iMC4wNCIvPjwvc3ZnPg=='); - background-size: 200px 200px; - opacity: var(--glass-noise-opacity); - pointer-events: none; - backface-visibility: hidden; - transform: translateZ(0); - will-change: transform; -} - -@keyframes ambient-float { - 0% { - transform: translate3d(-1.5%, -1%, 0) scale(1); - } - 100% { - transform: translate3d(1.5%, 1%, 0) scale(1.02); - } -} - @layer utilities { .bubble { background: radial-gradient( 120% 100% at 10% 0%, - color-mix(in oklch, var(--glass-tint, var(--primary)) 10%, transparent) 0%, + color-mix(in oklch, var(--glass-tint, var(--primary)) 7%, transparent) 0%, transparent 60% ), radial-gradient( @@ -754,10 +701,8 @@ body::after { transparent 60% ); background-color: color-mix(in oklch, var(--glass-base, var(--bg-surface)) 92%, transparent); - border-radius: var(--radius-xl); - box-shadow: - 0 8px 24px -8px color-mix(in oklch, var(--glass-shadow-color) 70%, transparent), - 0 1px 0 0 color-mix(in oklch, var(--glass-base, var(--bg-surface)) 30%, transparent) inset; + border-radius: var(--radius-lg); + box-shadow: 0 2px 8px -4px color-mix(in oklch, var(--glass-shadow-color) 50%, transparent); } .bubble-pill { @@ -767,15 +712,11 @@ body::after { } .bubble-shadow { - box-shadow: - 0 8px 30px -12px color-mix(in oklch, var(--glass-shadow-color) 70%, transparent), - 0 1px 0 0 color-mix(in oklch, var(--glass-base, var(--bg-surface)) 30%, transparent) inset; + box-shadow: 0 1px 2px -1px color-mix(in oklch, var(--glass-shadow-color) 60%, transparent); } .bubble-shadow-lg { - box-shadow: - 0 14px 40px -14px color-mix(in oklch, var(--glass-shadow-color) 80%, transparent), - 0 1px 0 0 color-mix(in oklch, var(--glass-base, var(--bg-surface)) 35%, transparent) inset; + box-shadow: 0 6px 20px -10px color-mix(in oklch, var(--glass-shadow-color) 60%, transparent); } .hover-lift { @@ -785,7 +726,7 @@ body::after { } .hover-lift:hover { - transform: translateY(-2px) translateZ(0); + transform: translateZ(0); } @media (prefers-reduced-motion: reduce) { @@ -804,38 +745,6 @@ body::after { border: 1px solid transparent; border-radius: var(--radius-lg); } - - /* Floating blob utility for accent backgrounds on sections */ - .blob-floating { - position: relative; - isolation: isolate; - } - .blob-floating::before { - content: ''; - position: absolute; - inset: -20%; - z-index: -1; - background: - radial-gradient( - 40% 40% at 20% 20%, - color-mix(in oklch, var(--glass-tint, var(--primary)) 20%, transparent) 0%, - transparent 60% - ), - radial-gradient(40% 50% at 80% 30%, color-mix(in oklch, var(--chart-2) 14%, transparent) 0%, transparent 60%); - filter: blur(60px) saturate(105%); - opacity: 0.22; - animation: blob-float var(--blob-speed, 18s) ease-in-out infinite alternate; - will-change: transform; - } - - @keyframes blob-float { - 0% { - transform: translate3d(-2%, 0, 0) scale(1); - } - 100% { - transform: translate3d(2%, 1%, 0) scale(1.05); - } - } } .version-collapsed { diff --git a/frontend/src/app.html b/frontend/src/app.html index 214370ae16..d5eec40825 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -14,7 +14,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte b/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte index 4a97d858a5..f7863ceefd 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte +++ b/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte @@ -214,7 +214,7 @@
diff --git a/frontend/src/lib/components/badges/port-badge.svelte b/frontend/src/lib/components/badges/port-badge.svelte index 057119bcaf..40d1c135bc 100644 --- a/frontend/src/lib/components/badges/port-badge.svelte +++ b/frontend/src/lib/components/badges/port-badge.svelte @@ -91,7 +91,7 @@ {p.containerPort} {#if p.proto} @@ -133,7 +133,7 @@ diff --git a/frontend/src/lib/components/badges/status-badge.svelte b/frontend/src/lib/components/badges/status-badge.svelte index 35cca66708..8750a9a6e3 100644 --- a/frontend/src/lib/components/badges/status-badge.svelte +++ b/frontend/src/lib/components/badges/status-badge.svelte @@ -86,7 +86,7 @@ const badgeClasses = $derived( cn( 'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-[var(--radius)] font-semibold tracking-tight', - 'border transition-all duration-300', + 'border transition-colors', sizeStyles[size as Size], variantStyles[variant as Variant], minWidthClasses[minWidth as MinWidth], diff --git a/frontend/src/lib/components/dialogs/docker-info-dialog.svelte b/frontend/src/lib/components/dialogs/docker-info-dialog.svelte index d68ff5cf96..1342c4d249 100644 --- a/frontend/src/lib/components/dialogs/docker-info-dialog.svelte +++ b/frontend/src/lib/components/dialogs/docker-info-dialog.svelte @@ -26,6 +26,17 @@ if (!timeStr) return '-'; return formatDateTimeShort(timeStr) || timeStr; } + + function shortCommit(commit: { ID?: string } | undefined | null): string { + const id = commit?.ID; + if (!id) return '-'; + return id.length > 12 ? id.slice(0, 12) : id; + } + + function swarmActive(info: DockerInfo): boolean { + const state = (info.Swarm as { LocalNodeState?: string } | undefined)?.LocalNodeState; + return !!state && state !== 'inactive'; + } {#snippet dialogBody(info: DockerInfo)} -
-
- {@render statsSection(info)} - {@render resourcesSection(info)} -
+
+ {#if info.Warnings && info.Warnings.length > 0} + {@render warningsCard(info.Warnings)} + {/if} -
+ {@render statsSection(info)} + {@render resourcesSection(info)} + +
{@render systemSection(info)} {@render versionSection(info)} {@render configurationSection(info)} -
- -
- {@render networkSection(info)} + {@render capabilitiesSection(info)} + {#if info.DriverStatus && info.DriverStatus.length > 0} + {@render storageDetailsSection(info)} + {/if} {@render securitySection(info)} {@render pluginsSection(info)} + {#if swarmActive(info)} + {@render swarmSection(info)} + {/if} + {#if info.Labels && info.Labels.length > 0} + {@render labelsSection(info)} + {/if} + {@render networkSection(info)}
{/snippet} +{#snippet warningsCard(warnings: string[])} +
+

+ {m.docker_info_warnings_section()} +

+
    + {#each warnings as warning, i (i)} +
  • {warning}
  • + {/each} +
+
+{/snippet} + {#snippet statsSection(info: DockerInfo)}

@@ -99,20 +132,51 @@

{/snippet} +{#snippet resourcesSection(info: DockerInfo)} +
+

+ {m.docker_info_resources_section()} +

+
+
+
{m.common_cpus()}
+
+ {info.NCPU ?? 0} + cores +
+
+
+
{m.docker_info_memory_label()}
+ {info.MemTotal ? bytes.format(info.MemTotal) : '-'} +
+
+
{m.docker_info_goroutines()}
+ {info.NGoroutines ?? 0} +
+
+
{m.docker_info_file_descriptors()}
+ {info.NFd ?? 0} +
+
+
+{/snippet} + {#snippet systemSection(info: DockerInfo)}

{m.docker_info_system_section()}

-
+
{@render infoRow(m.common_name(), info.Name)} {@render infoRow(m.common_id(), info.ID, true)} {@render infoRow(m.docker_info_os_label(), info.OperatingSystem)} + {@render infoRow(m.docker_info_os_version_label(), info.OSVersion)} {@render infoRow(m.docker_info_os_type_label(), info.OSType)} {@render infoRow(m.common_architecture(), info.Architecture)} {@render infoRow(m.docker_info_kernel_version_label(), info.KernelVersion)} {@render infoRow(m.docker_info_system_time(), formatTime(info.SystemTime), false)} {@render infoRow(m.docker_info_root_dir(), info.DockerRootDir, true)} + {@render infoRow(m.docker_info_index_server_label(), info.IndexServerAddress, true)}
{/snippet} @@ -122,13 +186,13 @@

{m.docker_info_version_section()}

-
+
{@render infoRow(m.docker_info_server_version_label(), info.ServerVersion)} {@render infoRow(m.docker_info_api_version_label(), info.apiVersion)} {@render infoRow(m.docker_info_go_version_label(), info.goVersion)} -
- {m.docker_info_git_commit_label()} -
+
+ {m.docker_info_git_commit_label()} +
{info.gitCommit?.slice(0, 8) ?? '-'} {#if info.gitCommit} @@ -137,35 +201,12 @@
{@render infoRow(m.docker_info_build_time_label(), formatTime(info.buildTime), false)} {@render infoRow(m.docker_info_experimental(), info.ExperimentalBuild ? m.common_yes() : m.common_no(), false)} -
-
-{/snippet} - -{#snippet resourcesSection(info: DockerInfo)} -
-

- {m.docker_info_resources_section()} -

-
-
-
{m.common_cpus()}
-
- {info.NCPU ?? 0} - cores -
-
-
-
{m.docker_info_memory_label()}
- {info.MemTotal ? bytes.format(info.MemTotal) : '-'} -
-
-
{m.docker_info_goroutines()}
- {info.NGoroutines ?? 0} -
-
-
{m.docker_info_file_descriptors()}
- {info.NFd ?? 0} -
+ {@render infoRow(m.docker_info_containerd_commit_label(), shortCommit(info.ContainerdCommit), true)} + {@render infoRow(m.docker_info_runc_commit_label(), shortCommit(info.RuncCommit), true)} + {@render infoRow(m.docker_info_init_commit_label(), shortCommit(info.InitCommit), true)} + {#if info.ProductLicense} + {@render infoRow(m.docker_info_product_license_label(), info.ProductLicense, false)} + {/if}
{/snippet} @@ -175,7 +216,7 @@

{m.common_configuration()}

-
+
{@render infoRow(m.docker_info_storage_driver_label(), info.Driver)} {@render infoRow(m.docker_info_logging_driver_label(), info.LoggingDriver)} {@render infoRow(m.docker_info_cgroup_driver_label(), info.CgroupDriver)} @@ -183,6 +224,41 @@ {@render infoRow(m.docker_info_isolation(), info.Isolation)} {@render infoRow(m.docker_info_init_binary(), info.InitBinary)} {@render infoRow(m.docker_info_default_runtime(), info.DefaultRuntime)} + {@render infoRow(m.docker_info_debug_label(), info.Debug ? m.common_yes() : m.common_no(), false)} + {@render infoRow(m.docker_info_live_restore_label(), info.LiveRestoreEnabled ? m.common_yes() : m.common_no(), false)} + {@render infoRow(m.docker_info_event_listeners_label(), info.NEventsListener ?? 0, false)} +
+
+{/snippet} + +{#snippet capabilitiesSection(info: DockerInfo)} +
+

+ {m.docker_info_capabilities_section()} +

+
+ {@render capRow(m.docker_info_memory_limit_label(), info.MemoryLimit)} + {@render capRow(m.docker_info_swap_limit_label(), info.SwapLimit)} + {@render capRow(m.docker_info_kernel_memory_tcp_label(), info.KernelMemoryTCP)} + {@render capRow(m.docker_info_cpu_cfs_period_label(), info.CpuCfsPeriod)} + {@render capRow(m.docker_info_cpu_cfs_quota_label(), info.CpuCfsQuota)} + {@render capRow(m.docker_info_cpu_shares_label(), info.CPUShares)} + {@render capRow(m.docker_info_cpu_set_label(), info.CPUSet)} + {@render capRow(m.docker_info_pids_limit_label(), info.PidsLimit)} + {@render capRow(m.docker_info_oom_kill_disable_label(), info.OomKillDisable)} +
+
+{/snippet} + +{#snippet storageDetailsSection(info: DockerInfo)} +
+

+ {m.docker_info_storage_details_section()} +

+
+ {#each info.DriverStatus ?? [] as entry, i (i)} + {@render infoRow(entry[0] ?? '', entry[1], false)} + {/each}
{/snippet} @@ -192,12 +268,33 @@

{m.resource_networks_cap()} & {m.docker_info_proxy_label()}

-
+
{@render infoRow(m.docker_info_ipv4_forwarding(), info.IPv4Forwarding ? m.common_enabled() : m.common_disabled(), false)} {@render infoRow(m.docker_info_http_proxy(), info.HttpProxy)} {@render infoRow(m.docker_info_https_proxy(), info.HttpsProxy)} {@render infoRow(m.docker_info_no_proxy(), info.NoProxy)} - {@render infoRow(m.docker_info_bridge_ip(), info.DefaultAddressPools?.[0]?.Base)} + {#if info.DefaultAddressPools && info.DefaultAddressPools.length > 0} +
+
{m.docker_info_address_pools_label()}
+
+ {#each info.DefaultAddressPools as pool, i (i)} + {pool.Base}/{pool.Size} + {/each} +
+
+ {/if} +
+
+{/snippet} + +{#snippet securitySection(info: DockerInfo)} +
+

+ {m.security_title()} & {m.docker_info_runtimes()} +

+
+ {@render tagGroup(m.docker_info_security_options(), info.SecurityOptions)} + {@render tagGroup(m.docker_info_runtimes(), Object.keys(info.Runtimes ?? {}))}
{/snippet} @@ -207,66 +304,47 @@

{m.docker_info_plugins_section()}

-
-
-
{m.resource_volumes_cap()}
-
- {#each info.Plugins?.Volume ?? [] as plugin} - {plugin} - {:else} - - - {/each} -
-
-
-
{m.resource_networks_cap()}
-
- {#each info.Plugins?.Network ?? [] as plugin} - {plugin} - {:else} - - - {/each} -
-
-
-
{m.docker_info_logs_plugin()}
-
- {#each info.Plugins?.Log ?? [] as plugin} - {plugin} - {:else} - - - {/each} -
-
+
+ {@render tagGroup(m.resource_volumes_cap(), info.Plugins?.Volume)} + {@render tagGroup(m.resource_networks_cap(), info.Plugins?.Network)} + {@render tagGroup(m.docker_info_logs_plugin(), info.Plugins?.Log)} + {@render tagGroup(m.docker_info_authorization_plugin(), info.Plugins?.Authorization)}
{/snippet} -{#snippet securitySection(info: DockerInfo)} +{#snippet swarmSection(info: DockerInfo)} + {@const swarm = info.Swarm as { + LocalNodeState?: string; + ControlAvailable?: boolean; + NodeID?: string; + Managers?: number; + Nodes?: number; + }}

- {m.security_title()} & {m.docker_info_runtimes()} + {m.docker_info_swarm_section()}

-
-
-
{m.docker_info_security_options()}
-
- {#each info.SecurityOptions ?? [] as opt} - {opt} - {:else} - - - {/each} -
-
-
-
{m.docker_info_runtimes()}
-
- {#each Object.keys(info.Runtimes ?? {}) as runtime} - {runtime} - {:else} - - - {/each} -
+
+ {@render infoRow(m.docker_info_swarm_state_label(), swarm.LocalNodeState, false)} + {@render infoRow(m.docker_info_swarm_manager_label(), swarm.ControlAvailable ? m.common_yes() : m.common_no(), false)} + {@render infoRow(m.docker_info_swarm_node_id_label(), swarm.NodeID, true)} + {@render infoRow(m.docker_info_swarm_managers_label(), swarm.Managers ?? 0, false)} + {@render infoRow(m.docker_info_swarm_nodes_label(), swarm.Nodes ?? 0, false)} +
+
+{/snippet} + +{#snippet labelsSection(info: DockerInfo)} +
+

+ {m.docker_info_labels_section()} +

+
+
+ {#each info.Labels ?? [] as label, i (i)} + {label} + {/each}
@@ -303,11 +381,33 @@
{/snippet} -{#snippet infoRow(label: string, value: string | undefined | null, mono: boolean = true)} +{#snippet tagGroup(label: string, items: string[] | undefined)} +
+
{label}
+
+ {#each items ?? [] as item, i (i)} + {item} + {:else} + - + {/each} +
+
+{/snippet} + +{#snippet capRow(label: string, value: boolean | undefined)} +
+ {label} + + {value ? m.common_yes() : m.common_no()} + +
+{/snippet} + +{#snippet infoRow(label: string, value: string | number | undefined | null, mono: boolean = true)}
{label} - {value || '-'} + {value === undefined || value === null || value === '' ? '-' : value}
{/snippet} diff --git a/frontend/src/lib/components/header-card.svelte b/frontend/src/lib/components/header-card.svelte index 38f2156efd..f443f35c89 100644 --- a/frontend/src/lib/components/header-card.svelte +++ b/frontend/src/lib/components/header-card.svelte @@ -12,13 +12,10 @@
-
-
-
{@render children()}
diff --git a/frontend/src/lib/components/quick-actions.svelte b/frontend/src/lib/components/quick-actions.svelte index b3c27fbbd7..41eb9f5ad8 100644 --- a/frontend/src/lib/components/quick-actions.svelte +++ b/frontend/src/lib/components/quick-actions.svelte @@ -3,7 +3,6 @@ import { ActionButtonGroup, type ActionButton } from '$lib/components/action-button-group/index.js'; import { IsTablet } from '$lib/hooks/is-tablet.svelte.js'; import { m } from '$lib/paraglide/messages'; - import { StartIcon, StopIcon, TrashIcon } from '$lib/icons'; import { cn } from '$lib/utils'; import { hasAnyPermission, hasPermission } from '$lib/utils/auth'; import { environmentStore } from '$lib/stores/environment.store.svelte'; @@ -45,7 +44,6 @@ const canStartAll = $derived(hasPermission('containers:start', currentEnvId)); const canStopAll = $derived(hasPermission('containers:stop', currentEnvId)); const canPrune = $derived(hasAnyPermission(['images:prune', 'volumes:prune', 'networks:prune'], currentEnvId)); - const hasAnyQuickAction = $derived(canStartAll || canStopAll || canPrune); const actionButtons: ActionButton[] = $derived( [ @@ -115,100 +113,5 @@ {:else} {/if} - {:else if hasAnyQuickAction} -

{m.quick_actions_title()}

- -
- {#if canStartAll} -
- -
-
- -
-
-
-
-
{m.quick_actions_start_all()}
-
- {m.quick_actions_containers({ count: stoppedContainers })} -
-
-
-
- {/if} - - {#if canStopAll} -
- -
-
- -
-
-
-
-
{m.quick_actions_stop_all()}
-
- {m.quick_actions_containers({ count: runningContainers })} -
-
-
-
- {/if} - - {#if canPrune} -
- -
-
- -
-
-
-
-
{m.quick_actions_prune_system()}
-
{m.quick_actions_prune_description()}
-
-
-
- {/if} -
{/if} diff --git a/frontend/src/lib/components/stat-card.svelte b/frontend/src/lib/components/stat-card.svelte index bbed1bf49a..0240ea18d1 100644 --- a/frontend/src/lib/components/stat-card.svelte +++ b/frontend/src/lib/components/stat-card.svelte @@ -38,7 +38,7 @@ {value} - + {title}
@@ -66,22 +66,17 @@ {:else}
-
-

{title}

-

+

{value}

{#if subtitle} @@ -89,14 +84,8 @@ {/if}
-
- +
+
diff --git a/frontend/src/lib/components/tab-bar/tab-bar.svelte b/frontend/src/lib/components/tab-bar/tab-bar.svelte index 5fbe1f6d0c..7cbaecd0be 100644 --- a/frontend/src/lib/components/tab-bar/tab-bar.svelte +++ b/frontend/src/lib/components/tab-bar/tab-bar.svelte @@ -28,7 +28,7 @@ {item.label} {#if item.badge !== undefined} {item.badge} diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte index 206f9400ef..582e2673a4 100644 --- a/frontend/src/lib/components/ui/card/card-header.svelte +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -2,7 +2,6 @@ import { cn, type WithElementRef } from '$lib/utils.js'; import type { HTMLAttributes } from 'svelte/elements'; import { Spinner } from '$lib/components/ui/spinner/index.js'; - import { mode } from 'mode-watcher'; let { ref = $bindable(null), @@ -25,25 +24,17 @@ > = $props(); const iconVariantClasses = { - primary: 'from-primary to-primary/80 shadow-primary/25 border border-primary/20', - emerald: 'from-emerald-500 to-emerald-600 shadow-emerald-500/25 border border-emerald-400/20', - red: 'from-red-500 to-red-600 shadow-red-500/25 border border-red-400/20', - amber: 'from-amber-500 to-amber-600 shadow-amber-500/25 border border-amber-400/20', - blue: 'from-blue-500 to-blue-600 shadow-blue-500/25 border border-blue-400/20', - purple: 'from-purple-500 to-purple-600 shadow-purple-500/25 border border-purple-400/20', - cyan: 'from-cyan-500 to-cyan-600 shadow-cyan-500/25 border border-cyan-400/20', - orange: 'from-orange-500 to-orange-600 shadow-orange-500/25 border border-orange-400/20', - indigo: 'from-indigo-500 to-indigo-600 shadow-indigo-500/25 border border-indigo-400/20', - pink: 'from-pink-500 to-pink-600 shadow-pink-500/25 border border-pink-400/20' + primary: 'bg-primary/10 text-primary ring-1 ring-primary/20', + emerald: 'bg-emerald-500/10 text-emerald-600 ring-1 ring-emerald-500/20 dark:text-emerald-400', + red: 'bg-red-500/10 text-red-600 ring-1 ring-red-500/20 dark:text-red-400', + amber: 'bg-amber-500/10 text-amber-600 ring-1 ring-amber-500/20 dark:text-amber-400', + blue: 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/20 dark:text-blue-400', + purple: 'bg-purple-500/10 text-purple-600 ring-1 ring-purple-500/20 dark:text-purple-400', + cyan: 'bg-cyan-500/10 text-cyan-600 ring-1 ring-cyan-500/20 dark:text-cyan-400', + orange: 'bg-orange-500/10 text-orange-600 ring-1 ring-orange-500/20 dark:text-orange-400', + indigo: 'bg-indigo-500/10 text-indigo-600 ring-1 ring-indigo-500/20 dark:text-indigo-400', + pink: 'bg-pink-500/10 text-pink-600 ring-1 ring-pink-500/20 dark:text-pink-400' }; - - const isDarkMode = $derived(mode.current === 'dark'); - - const headerHoverClass = $derived( - isDarkMode - ? 'group-[&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:from-primary/8 group-[&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:to-primary/2' - : 'group-[&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:from-primary/6 group-[&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:to-primary/2' - );
- -
- - -
- {#if icon} {@const IconComponent = loading ? Spinner : icon}
- -
- +
{/if} diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte index d82d235160..9e8678a02e 100644 --- a/frontend/src/lib/components/ui/card/card.svelte +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -28,13 +28,13 @@ function getVariantClasses(variant: 'default' | 'subtle' | 'outlined') { switch (variant) { case 'default': - return 'backdrop-blur-sm bg-white/10 shadow-sm dark:bg-surface/10'; + return 'backdrop-blur-md bg-card/60 shadow-xs dark:bg-surface/40'; case 'subtle': - return 'backdrop-blur-sm bg-white/10 dark:bg-surface/10'; + return 'bg-muted/40 dark:bg-surface/30'; case 'outlined': - return 'backdrop-blur-sm bg-white/10 dark:bg-surface/10 border border-border/60'; + return 'bg-card/50 backdrop-blur-md border-border/70'; default: - return 'backdrop-blur-sm bg-white/10 shadow-sm dark:bg-surface/10'; + return 'backdrop-blur-md bg-card/60 shadow-xs dark:bg-surface/40'; } } @@ -43,10 +43,10 @@ bind:this={ref} data-slot="card" class={cn( - 'text-card-foreground group dark:border-border/80 relative isolate gap-0 overflow-hidden rounded-xl border border-white/80 p-0 transition-all duration-300', + 'text-card-foreground group border-border/70 relative isolate gap-0 overflow-hidden rounded-xl border p-0 transition-colors duration-200', getVariantClasses(variant), onclick - ? '[&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:bg-muted/60 cursor-pointer [&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:shadow-md' + ? '[&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:bg-muted/60 cursor-pointer [&:not(:has(button:hover,a:hover,[role=button]:hover))]:hover:shadow-sm' : '', className )} diff --git a/frontend/src/lib/components/ui/drawer/drawer-content.svelte b/frontend/src/lib/components/ui/drawer/drawer-content.svelte index e2d594bb1d..8e66a5b78f 100644 --- a/frontend/src/lib/components/ui/drawer/drawer-content.svelte +++ b/frontend/src/lib/components/ui/drawer/drawer-content.svelte @@ -20,7 +20,7 @@ bind:ref data-slot="drawer-content" class={cn( - 'group/drawer-content text-foreground dark:bg-surface/10 dark:border-border/80 fixed z-50 flex h-auto flex-col border-white/80 bg-white/10 backdrop-blur-md', + 'group/drawer-content text-foreground dark:bg-surface/10 fixed z-50 flex h-auto flex-col border-border/70 bg-white/10 backdrop-blur-md', 'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b', 'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t', 'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm', diff --git a/frontend/src/lib/components/ui/sheet/sheet-content.svelte b/frontend/src/lib/components/ui/sheet/sheet-content.svelte index dfa10d5e78..b000b6d9a1 100644 --- a/frontend/src/lib/components/ui/sheet/sheet-content.svelte +++ b/frontend/src/lib/components/ui/sheet/sheet-content.svelte @@ -1,7 +1,7 @@ -
-
-
+
+
+
{#if Icon}
{/if}
-

{title}

+

{title}

{#if subtitle}

{subtitle}

{/if} -
-
- - {#if statCards && statCards.length > 0} -
diff --git a/frontend/src/lib/layouts/settings-page-layout.svelte b/frontend/src/lib/layouts/settings-page-layout.svelte index d55dd02a5c..6f2e18c06d 100644 --- a/frontend/src/lib/layouts/settings-page-layout.svelte +++ b/frontend/src/lib/layouts/settings-page-layout.svelte @@ -50,7 +50,7 @@
-
+
{#if Icon}
{/if}
-

{title}

+

{title}

{#if description} {/if} -
-
- - {#if pageType === 'management' && statCards && statCards.length > 0} -
{#if showReadOnlyTag} diff --git a/frontend/src/lib/layouts/tabbed-page-layout.svelte b/frontend/src/lib/layouts/tabbed-page-layout.svelte index 8b5fd4e229..3558e188a1 100644 --- a/frontend/src/lib/layouts/tabbed-page-layout.svelte +++ b/frontend/src/lib/layouts/tabbed-page-layout.svelte @@ -86,7 +86,7 @@ {#if showFloatingHeader}
diff --git a/frontend/src/lib/query/query-keys.ts b/frontend/src/lib/query/query-keys.ts index 26ea151362..415b8900fb 100644 --- a/frontend/src/lib/query/query-keys.ts +++ b/frontend/src/lib/query/query-keys.ts @@ -77,13 +77,6 @@ export const queryKeys = { versionInfo: (environmentId: string) => ['system', 'version-info', environmentId] as const, dockerInfo: (environmentId: string) => ['system', 'docker-info', environmentId] as const }, - dashboard: { - snapshot: (environmentId: string, debugAllGood = false) => - ['dashboard', 'snapshot', environmentId, debugAllGood ? 'debug-all-good' : 'normal'] as const, - actionItems: (environmentId: string, debugAllGood = false) => - ['dashboard', 'action-items', environmentId, debugAllGood ? 'debug-all-good' : 'normal'] as const, - environments: (debugAllGood = false) => ['dashboard', 'environments', debugAllGood ? 'debug-all-good' : 'normal'] as const - }, containers: { all: ['containers'] as const, list: (environmentId: string, options: SearchPaginationSortRequest) => diff --git a/frontend/src/lib/services/dashboard-service.ts b/frontend/src/lib/services/dashboard-service.ts index b375f8391b..9dad8f0cdf 100644 --- a/frontend/src/lib/services/dashboard-service.ts +++ b/frontend/src/lib/services/dashboard-service.ts @@ -1,39 +1,15 @@ import BaseAPIService from './api-service'; -import { environmentStore } from '$lib/stores/environment.store.svelte'; -import type { DashboardActionItems, DashboardEnvironmentsOverview, DashboardSnapshot } from '$lib/types/shared'; +import type { DashboardSnapshot } from '$lib/types/shared'; -interface GetDashboardActionItemsOptions { +interface GetDashboardOptions { debugAllGood?: boolean; } export class DashboardService extends BaseAPIService { - async getDashboard(options?: GetDashboardActionItemsOptions): Promise { - const envId = await environmentStore.getCurrentEnvironmentId(); - return this.getDashboardForEnvironment(envId, options); - } - - async getDashboardForEnvironment(environmentId: string, options?: GetDashboardActionItemsOptions): Promise { + async getDashboardForEnvironment(environmentId: string, options?: GetDashboardOptions): Promise { const params = options?.debugAllGood ? { debugAllGood: 'true' } : undefined; return this.handleResponse(this.api.get(`/environments/${environmentId}/dashboard`, { params })); } - - async getActionItems(): Promise { - const envId = await environmentStore.getCurrentEnvironmentId(); - return this.getActionItemsForEnvironment(envId); - } - - async getActionItemsForEnvironment( - environmentId: string, - options?: GetDashboardActionItemsOptions - ): Promise { - const params = options?.debugAllGood ? { debugAllGood: 'true' } : undefined; - return this.handleResponse(this.api.get(`/environments/${environmentId}/dashboard/action-items`, { params })); - } - - async getDashboardEnvironmentsOverview(options?: GetDashboardActionItemsOptions): Promise { - const params = options?.debugAllGood ? { debugAllGood: 'true' } : undefined; - return this.handleResponse(this.api.get('/dashboard/environments', { params })); - } } export const dashboardService = new DashboardService(); diff --git a/frontend/src/lib/types/shared.ts b/frontend/src/lib/types/shared.ts index ba62b5a775..b1d1d1301f 100644 --- a/frontend/src/lib/types/shared.ts +++ b/frontend/src/lib/types/shared.ts @@ -194,6 +194,7 @@ export interface DashboardSnapshot { imageUsageCounts: ImageUsageCounts; actionItems: DashboardActionItems; settings: DashboardSnapshotSettings; + versionInfo?: AppVersionInformation; } export type EnvironmentDashboardSnapshotState = 'ready' | 'skipped' | 'error'; @@ -209,24 +210,6 @@ export interface DashboardEnvironmentOverview { snapshotError?: string; } -export interface DashboardEnvironmentsSummary { - totalEnvironments: number; - onlineEnvironments: number; - standbyEnvironments: number; - offlineEnvironments: number; - pendingEnvironments: number; - errorEnvironments: number; - disabledEnvironments: number; - containers: ContainerStatusCounts; - imageUsageCounts: ImageUsageCounts; - environmentsWithActionItems: number; -} - -export interface DashboardEnvironmentsOverview { - summary: DashboardEnvironmentsSummary; - environments: DashboardEnvironmentOverview[]; -} - export interface DashboardOverviewSummary { totalEnvironments: number; reachableEnvironments: number; diff --git a/frontend/src/routes/(app)/dashboard/+page.svelte b/frontend/src/routes/(app)/dashboard/+page.svelte index 264409e25b..5ddc00cdb2 100644 --- a/frontend/src/routes/(app)/dashboard/+page.svelte +++ b/frontend/src/routes/(app)/dashboard/+page.svelte @@ -1,224 +1,28 @@ - -
- -
- - {#if activeView === 'all'} - - - - {:else} - -
-
-
-
-

{m.dashboard_title()}

-

{currentEnvironmentName}

-
- - (isPruneDialogOpen = true)} - onRefresh={refreshData} - refreshing={isLoading.refreshing} - /> -
-
- -
- {#if attentionItemsCount > 0} -
-

{m.dashboard_action_items_title()}

-
-
- {#if stoppedContainersAttentionCount > 0} - - {/if} - - {#if imageUpdatesAttentionCount > 0} - - {/if} - - {#if actionableVulnerabilitiesAttentionCount > 0} - - {/if} - - {#if hasDiskPressureAlert} - - {/if} - - {#if expiringApiKeysAttentionCount > 0} - - {/if} -
- {:else} - - -
-
- - {m.progress_deploy_service_healthy({ service: m.environments_title() })} -
-

{m.dashboard_no_actionable_events()}

-
-
-
- {/if} -
- -
- - -
-

{m.common_overview()}

-

{m.dashboard_overview_caption()}

-
-
- -
- - - - - - - {#if gpuMetric !== null} - - {/if} -
- -
-
-
{m.docker_engine_title({ engine: overviewHost })}
-
- - - {runningContainers} - / - {totalContainers} - - - - - {images.pagination.totalItems} - {m.images_title()} - - - {imageUsageLabel} - - {overviewPlatform} / {overviewArchitecture} -
-
- - -
-
-
-
- -
-
-

{m.dashboard_resource_tables_title()}

- -
-
- - -
-
- - - - (isPruneDialogOpen = false)} - /> -
-
- {/if} -
+ diff --git a/frontend/src/routes/(app)/dashboard/+page.ts b/frontend/src/routes/(app)/dashboard/+page.ts index 3b2b70710b..080f0f6cd2 100644 --- a/frontend/src/routes/(app)/dashboard/+page.ts +++ b/frontend/src/routes/(app)/dashboard/+page.ts @@ -1,44 +1,9 @@ -import { dashboardService } from '$lib/services/dashboard-service'; -import { settingsService } from '$lib/services/settings-service'; -import { queryKeys } from '$lib/query/query-keys'; -import { environmentStore } from '$lib/stores/environment.store.svelte'; -import { throwPageLoadError } from '$lib/utils/api'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ parent, url }) => { - const { queryClient } = await parent(); +export const load: PageLoad = async ({ url }) => { const debugAllGood = url.searchParams.get('debugAllGood') === 'true'; - const requestedView = url.searchParams.get('view'); - const view = requestedView === 'current' ? 'current' : 'all'; - try { - if (view === 'all') { - return { - view, - dashboard: null, - debugAllGood - }; - } - - const envId = await environmentStore.getCurrentEnvironmentId(); - const [dashboard, settings] = await Promise.all([ - queryClient.fetchQuery({ - queryKey: queryKeys.dashboard.snapshot(envId, debugAllGood), - queryFn: () => dashboardService.getDashboardForEnvironment(envId, { debugAllGood }) - }), - queryClient.fetchQuery({ - queryKey: queryKeys.settings.byEnvironment(envId), - queryFn: () => settingsService.getSettingsForEnvironmentMerged(envId) - }) - ]); - - return { - view, - dashboard, - settings, - debugAllGood - }; - } catch (err) { - throwPageLoadError(err, 'Failed to load dashboard data'); - } + return { + debugAllGood + }; }; diff --git a/frontend/src/routes/(app)/dashboard/dash-action-card.svelte b/frontend/src/routes/(app)/dashboard/dash-action-card.svelte index 8be482fa2a..3cf40264c6 100644 --- a/frontend/src/routes/(app)/dashboard/dash-action-card.svelte +++ b/frontend/src/routes/(app)/dashboard/dash-action-card.svelte @@ -37,7 +37,7 @@ let { title, value, description, icon: Icon, badgeText, badgeVariant = 'red', ctaLabel, href }: Props = $props(); - +
diff --git a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte index 22143addfc..1db7ba42cf 100644 --- a/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte +++ b/frontend/src/routes/(app)/dashboard/dashboard-all-environments-view.svelte @@ -1,12 +1,14 @@
-
+

{m.dashboard_title()}

@@ -657,7 +792,7 @@

{m.common_overview()}

- {#await environmentBoardStatePromise} + {#if boardSummaryLoading}
{#each [1, 2, 3, 4] as tile (tile)}
@@ -667,7 +802,7 @@
{/each}
- {:then boardState} + {:else} {@const summary = boardState.summary}
@@ -706,7 +841,7 @@
{formatStorageOverviewLabel(summary)}
- {/await} + {/if}
@@ -734,19 +869,17 @@ {@const cpuMetric = getCpuMetric(systemStats)} {@const memoryMetric = getMemoryMetric(systemStats)} {@const diskMetric = getDiskMetric(systemStats)} + {@const gpuMetric = getGpuMetric(systemStats)} - -
+ +
{environment.name}
- {#if isCurrent} - - {/if} - {#await environmentBoardStatePromise then boardState} + {#if boardState} {@const loadedItem = boardState.overviewById.get(environment.id) ?? baseItem} {@const vInfo = loadedItem.versionInfo || @@ -814,23 +947,43 @@ /> {/if} {/if} - {/await} + {/if}
-
+
{environment.apiUrl} {activity.label}: {activity.value}
-
- {#await environmentBoardStatePromise} - - {:then boardState} - {@const loadedItem = boardState.overviewById.get(environment.id) ?? baseItem} - - {/await} +
+ {#each getEnvironmentActionButtons(boardState.overviewById.get(environment.id) ?? baseItem, isCurrent) as btn (btn.id)} + {@const isActiveEnv = isCurrent && btn.id === `${environment.id}-use`} + + + {#snippet child({ props })} + + {/snippet} + + {isActiveEnv ? m.common_current() : btn.label} + + {/each}
@@ -840,12 +993,12 @@
{m.containers_title()}
- {#await environmentBoardStatePromise} + {#if isEnvironmentSnapshotLoading(environment.id)}
- {:then boardState} + {:else} {@const loadedItem = boardState.overviewById.get(environment.id) ?? baseItem}
{loadedItem.containers.runningContainers}/{loadedItem.containers.totalContainers} @@ -854,17 +1007,17 @@ {loadedItem.containers.stoppedContainers} {m.common_stopped()}
- {/await} + {/if}
{m.images_title()}
- {#await environmentBoardStatePromise} + {#if isEnvironmentSnapshotLoading(environment.id)}
- {:then boardState} + {:else} {@const loadedItem = boardState.overviewById.get(environment.id) ?? baseItem}
{loadedItem.imageUsageCounts.totalImages}
@@ -872,23 +1025,23 @@ {m.common_in_use()} · {loadedItem.imageUsageCounts.imagesUnused} {m.common_unused()}
- {/await} + {/if}
{m.dashboard_action_items_title()}
- {#await environmentBoardStatePromise} + {#if isEnvironmentSnapshotLoading(environment.id)}
- {:then boardState} + {:else} {@const loadedItem = boardState.overviewById.get(environment.id) ?? baseItem}
{loadedItem.actionItems.items.length}
{getActionSummary(loadedItem)}
- {/await} + {/if}
{:else} @@ -899,8 +1052,8 @@ {/if} {#if shouldLoadEnvironment(environment)} -
-
+
+
{#if liveStatsLoading} {#each [1, 2, 3] as tile (tile)}
@@ -938,19 +1091,29 @@ labelClass="truncate" meterValue={diskMetric} /> + + {#if gpuMetric !== null} + + {/if} {/if}
{/if} - {#await environmentBoardStatePromise then boardState} + {#if boardState} {@const loadedItem = boardState.overviewById.get(environment.id) ?? baseItem} {#if loadedItem.snapshotState === 'error' && loadedItem.snapshotError}
{m.dashboard_all_summary_unavailable({ error: loadedItem.snapshotError })}
{/if} - {/await} + {/if} {/each} @@ -965,3 +1128,5 @@ onConfirm={confirmPrune} onCancel={closePruneDialog} /> + + diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index d284212fe3..2eb8ed5622 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -132,7 +132,7 @@ } -
+
@@ -190,7 +190,7 @@
{#each settingsCategories as category (category.id)} {@const Icon = getIconComponent(category.icon)} - + +
+
+ {/if} + {#if activityStore.loading && activityStore.activities.length === 0}
diff --git a/frontend/src/lib/stores/activity.store.svelte.ts b/frontend/src/lib/stores/activity.store.svelte.ts index 90ed0fe8a1..0f7a7f1795 100644 --- a/frontend/src/lib/stores/activity.store.svelte.ts +++ b/frontend/src/lib/stores/activity.store.svelte.ts @@ -3,18 +3,31 @@ import { activityService } from '$lib/services/activity-service'; import { environmentStore, LOCAL_DOCKER_ENVIRONMENT_ID } from '$lib/stores/environment.store.svelte'; import type { Activity, + ActivityClearHistorySummary, ActivityDetail, + ActivityEnvironmentFailure, ActivityFilter, ActivityMessage, ActivityStatus, ActivityStreamEvent } from '$lib/types/activity.type'; +import type { Environment } from '$lib/types/environment'; const ACTIVITY_LIST_LIMIT = 50; const ACTIVITY_DETAIL_LIMIT = 500; const MAX_RECONNECT_DELAY = 15_000; const MAX_RECONNECT_ATTEMPTS = 20; +type ActivityEnvironmentState = { + id: string; + name: string; + activities: Activity[]; + loading: boolean; + connected: boolean; + streamError: boolean; + errorMessage?: string; +}; + function sortActivitiesInternal(items: Activity[]): Activity[] { return [...items].sort((a, b) => { const aActive = isActiveStatusInternal(a.status); @@ -44,8 +57,30 @@ function filterActivityInternal(activity: Activity, filter: ActivityFilter): boo } } +function sourceEnvironmentIdInternal(activity: Activity | null | undefined): string { + return activity?.sourceEnvironmentId || activity?.environmentId || LOCAL_DOCKER_ENVIRONMENT_ID; +} + +function environmentNameInternal( + environment: Pick | ActivityEnvironmentState | null | undefined +): string { + if (!environment) { + return 'Local'; + } + return environment.name || environment.id; +} + +function errorMessageInternal(error: unknown): string | undefined { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + return undefined; +} + function createActivityStore() { let _activities = $state([]); + let _environmentStates = $state>({}); + let _environmentActivities = $state>({}); let _details = $state>({}); let _expandedActivityIds = $state>({}); let _detailLoadingIds = $state>({}); @@ -53,65 +88,137 @@ function createActivityStore() { let _cancellingIds = $state>({}); let _filter = $state('running'); let _open = $state(false); - let _loading = $state(false); - let _connected = $state(false); - let _streamError = $state(false); let _currentEnvironmentId = $state(LOCAL_DOCKER_ENVIRONMENT_ID); let started = false; - let streamGeneration = 0; - let streamAbortController: AbortController | null = null; - let reconnectTimer: ReturnType | null = null; let unsubscribeEnvironment: (() => void) | null = null; - let reconnectAttempt = 0; + const streamAbortControllers = new Map(); + const reconnectTimers = new Map>(); + const reconnectAttempts = new Map(); + const streamGenerations = new Map(); + + function createEnvironmentStateInternal(environment: Pick): ActivityEnvironmentState { + return { + id: environment.id || LOCAL_DOCKER_ENVIRONMENT_ID, + name: environmentNameInternal(environment), + activities: [], + loading: true, + connected: false, + streamError: false + }; + } - function clearReconnectTimerInternal() { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } + function environmentStateInternal(environmentId: string): ActivityEnvironmentState | undefined { + return _environmentStates[environmentId]; } - function abortStreamInternal() { - clearReconnectTimerInternal(); - streamAbortController?.abort(); - streamAbortController = null; - _connected = false; + function updateEnvironmentStateInternal( + environmentId: string, + updater: (state: ActivityEnvironmentState) => ActivityEnvironmentState + ) { + const current = + _environmentStates[environmentId] ?? createEnvironmentStateInternal({ id: environmentId, name: environmentId }); + _environmentStates = { + ..._environmentStates, + [environmentId]: updater(current) + }; } - function resetEnvironmentStateInternal(environmentId: string) { - _currentEnvironmentId = environmentId || LOCAL_DOCKER_ENVIRONMENT_ID; - _activities = []; - _details = {}; - _expandedActivityIds = {}; - _detailLoadingIds = {}; - _detailErrorIds = {}; - _connected = false; - _streamError = false; - _loading = true; - reconnectAttempt = 0; + function setEnvironmentErrorInternal(environmentId: string, error: unknown) { + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + loading: false, + connected: false, + streamError: true, + errorMessage: errorMessageInternal(error) + })); } - async function refreshInternal(environmentId = _currentEnvironmentId, generation = streamGeneration) { - _loading = true; - try { - const result = await activityService.getActivities({ pagination: { page: 1, limit: ACTIVITY_LIST_LIMIT } }, environmentId); - if (generation !== streamGeneration || environmentId !== _currentEnvironmentId) { - return; - } - replaceSnapshotInternal(result.data ?? []); - } catch (error) { - console.warn('Failed to refresh activities:', error); - } finally { - if (generation === streamGeneration && environmentId === _currentEnvironmentId) { - _loading = false; - } + function clearEnvironmentErrorInternal(environmentId: string) { + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + streamError: false, + errorMessage: undefined + })); + } + + function generationInternal(environmentId: string): number { + return streamGenerations.get(environmentId) ?? 0; + } + + function nextGenerationInternal(environmentId: string): number { + const generation = generationInternal(environmentId) + 1; + streamGenerations.set(environmentId, generation); + return generation; + } + + function isCurrentGenerationInternal(environmentId: string, generation: number): boolean { + return generationInternal(environmentId) === generation; + } + + function clearReconnectTimerInternal(environmentId: string) { + const timer = reconnectTimers.get(environmentId); + if (timer) { + clearTimeout(timer); + reconnectTimers.delete(environmentId); } } - function replaceSnapshotInternal(activities: Activity[]) { - _activities = sortActivitiesInternal(activities); - // Drop expansion state for activities that no longer exist in the snapshot. + function abortEnvironmentStreamInternal(environmentId: string) { + clearReconnectTimerInternal(environmentId); + streamAbortControllers.get(environmentId)?.abort(); + streamAbortControllers.delete(environmentId); + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + connected: false + })); + } + + function stopEnvironmentInternal(environmentId: string) { + nextGenerationInternal(environmentId); + abortEnvironmentStreamInternal(environmentId); + reconnectAttempts.delete(environmentId); + streamGenerations.delete(environmentId); + + const nextStates = { ..._environmentStates }; + delete nextStates[environmentId]; + _environmentStates = nextStates; + const nextActivities = { ..._environmentActivities }; + delete nextActivities[environmentId]; + _environmentActivities = nextActivities; + rebuildActivitiesInternal(); + } + + function normalizeActivityInternal(activity: Activity, environmentId: string): Activity { + const state = environmentStateInternal(environmentId); + return { + ...activity, + sourceEnvironmentId: activity.sourceEnvironmentId || environmentId, + sourceEnvironmentName: activity.sourceEnvironmentName || state?.name || environmentId + }; + } + + function replaceEnvironmentSnapshotInternal(environmentId: string, activities: Activity[]) { + const normalizedActivities = sortActivitiesInternal( + activities.map((activity) => normalizeActivityInternal(activity, environmentId)) + ); + _environmentActivities = { + ..._environmentActivities, + [environmentId]: normalizedActivities + }; + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + activities: normalizedActivities, + loading: false, + streamError: false, + errorMessage: undefined + })); + rebuildActivitiesInternal(); + } + + function rebuildActivitiesInternal() { + _activities = sortActivitiesInternal(Object.values(_environmentActivities).flat()); + const present = new Set(_activities.map((activity) => activity.id)); const nextExpanded: Record = {}; for (const id of Object.keys(_expandedActivityIds)) { @@ -123,20 +230,36 @@ function createActivityStore() { } function mergeActivityInternal(activity: Activity) { - const index = _activities.findIndex((item) => item.id === activity.id); - if (index >= 0) { - _activities = sortActivitiesInternal([..._activities.slice(0, index), activity, ..._activities.slice(index + 1)]); - } else { - _activities = sortActivitiesInternal([activity, ..._activities]).slice(0, ACTIVITY_LIST_LIMIT); - } + const environmentId = sourceEnvironmentIdInternal(activity); + const normalized = normalizeActivityInternal(activity, environmentId); + const currentActivities = _environmentActivities[environmentId] ?? environmentStateInternal(environmentId)?.activities ?? []; + const index = currentActivities.findIndex((item) => item.id === normalized.id); + const activities = sortActivitiesInternal( + index >= 0 + ? [...currentActivities.slice(0, index), normalized, ...currentActivities.slice(index + 1)] + : [normalized, ...currentActivities] + ).slice(0, ACTIVITY_LIST_LIMIT); + _environmentActivities = { + ..._environmentActivities, + [environmentId]: activities + }; + updateEnvironmentStateInternal(environmentId, (state) => { + return { + ...state, + activities, + streamError: false, + errorMessage: undefined + }; + }); + rebuildActivitiesInternal(); - const existingDetail = _details[activity.id]; + const existingDetail = _details[normalized.id]; if (existingDetail) { _details = { ..._details, - [activity.id]: { + [normalized.id]: { ...existingDetail, - activity + activity: normalized } }; } @@ -159,15 +282,14 @@ function createActivityStore() { }; } - function applyStreamEventInternal(event: ActivityStreamEvent) { + function applyStreamEventInternal(environmentId: string, event: ActivityStreamEvent) { switch (event.type) { case 'snapshot': - replaceSnapshotInternal(event.activities ?? []); - _loading = false; + replaceEnvironmentSnapshotInternal(environmentId, event.activities ?? []); break; case 'activity': if (event.activity) { - mergeActivityInternal(event.activity); + mergeActivityInternal(normalizeActivityInternal(event.activity, environmentId)); } break; case 'message': @@ -176,48 +298,89 @@ function createActivityStore() { } break; case 'heartbeat': - _connected = true; + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + connected: true + })); break; } } + async function refreshEnvironmentInternal(environmentId: string, generation = generationInternal(environmentId)) { + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + loading: true + })); + try { + const result = await activityService.getActivities({ pagination: { page: 1, limit: ACTIVITY_LIST_LIMIT } }, environmentId); + if (!isCurrentGenerationInternal(environmentId, generation)) { + return; + } + replaceEnvironmentSnapshotInternal(environmentId, result.data ?? []); + } catch (error) { + if (isCurrentGenerationInternal(environmentId, generation)) { + console.warn('Failed to refresh activities:', error); + setEnvironmentErrorInternal(environmentId, error); + } + } + } + + async function refreshInternal() { + reconcileEnvironmentsInternal(); + await Promise.all(Object.keys(_environmentStates).map((environmentId) => refreshEnvironmentInternal(environmentId))); + } + async function connectStreamInternal(environmentId: string, generation: number) { - if (!browser || generation !== streamGeneration) { + if (!browser || !isCurrentGenerationInternal(environmentId, generation)) { return; } - streamAbortController = new AbortController(); + const controller = new AbortController(); + streamAbortControllers.set(environmentId, controller); try { - const response = await activityService.openActivityStream(environmentId, streamAbortController.signal, ACTIVITY_LIST_LIMIT); - if (generation !== streamGeneration || !response.body) { - streamAbortController = null; + const response = await activityService.openActivityStream(environmentId, controller.signal, ACTIVITY_LIST_LIMIT); + if (!isCurrentGenerationInternal(environmentId, generation) || !response.body) { + if (streamAbortControllers.get(environmentId) === controller) { + streamAbortControllers.delete(environmentId); + } return; } - _connected = true; - _streamError = false; - reconnectAttempt = 0; - await readJSONLinesInternal(response.body, generation); + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + connected: true, + streamError: false, + errorMessage: undefined + })); + reconnectAttempts.set(environmentId, 0); + await readJSONLinesInternal(environmentId, response.body, generation); } catch (error) { - if (!streamAbortController?.signal.aborted && generation === streamGeneration) { + if (!controller.signal.aborted && isCurrentGenerationInternal(environmentId, generation)) { console.warn('Activity stream disconnected:', error); } } finally { - streamAbortController = null; - if (generation === streamGeneration) { - _connected = false; - scheduleReconnectInternal(environmentId, generation); + if (streamAbortControllers.get(environmentId) === controller) { + streamAbortControllers.delete(environmentId); + } + if (isCurrentGenerationInternal(environmentId, generation)) { + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + connected: false + })); + if (!controller.signal.aborted) { + scheduleReconnectInternal(environmentId, generation); + } } } } - async function readJSONLinesInternal(stream: ReadableStream, generation: number) { + async function readJSONLinesInternal(environmentId: string, stream: ReadableStream, generation: number) { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { - while (generation === streamGeneration) { + while (isCurrentGenerationInternal(environmentId, generation)) { const { done, value } = await reader.read(); if (done) { break; @@ -227,59 +390,142 @@ function createActivityStore() { const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { - handleStreamLineInternal(line); + handleStreamLineInternal(environmentId, line); } } buffer += decoder.decode(); if (buffer.trim()) { - handleStreamLineInternal(buffer); + handleStreamLineInternal(environmentId, buffer); } } finally { reader.releaseLock(); } } - function handleStreamLineInternal(line: string) { + function handleStreamLineInternal(environmentId: string, line: string) { const trimmed = line.trim(); if (!trimmed) { return; } try { - applyStreamEventInternal(JSON.parse(trimmed) as ActivityStreamEvent); + applyStreamEventInternal(environmentId, JSON.parse(trimmed) as ActivityStreamEvent); } catch (error) { console.warn('Failed to parse activity stream line:', error); } } function scheduleReconnectInternal(environmentId: string, generation: number) { - if (!browser || !started || generation !== streamGeneration) { + if (!browser || !started || !isCurrentGenerationInternal(environmentId, generation)) { return; } - if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { - _streamError = true; + const attempt = reconnectAttempts.get(environmentId) ?? 0; + if (attempt >= MAX_RECONNECT_ATTEMPTS) { + setEnvironmentErrorInternal(environmentId, new Error('Activity stream reconnect attempts exhausted')); return; } - clearReconnectTimerInternal(); - const delay = Math.min(1000 * 2 ** reconnectAttempt, MAX_RECONNECT_DELAY); - reconnectAttempt += 1; - reconnectTimer = setTimeout(() => { - void connectStreamInternal(environmentId, generation); - }, delay); + clearReconnectTimerInternal(environmentId); + const delay = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY); + reconnectAttempts.set(environmentId, attempt + 1); + reconnectTimers.set( + environmentId, + setTimeout(() => { + void connectStreamInternal(environmentId, generation); + }, delay) + ); } - function restartForEnvironmentInternal(environmentId: string) { - streamGeneration += 1; - abortStreamInternal(); - resetEnvironmentStateInternal(environmentId); - const generation = streamGeneration; - void refreshInternal(environmentId, generation); + function startEnvironmentInternal(environment: Pick) { + const environmentId = environment.id || LOCAL_DOCKER_ENVIRONMENT_ID; + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + id: environmentId, + name: environmentNameInternal(environment) + })); + + const generation = nextGenerationInternal(environmentId); + reconnectAttempts.set(environmentId, 0); + abortEnvironmentStreamInternal(environmentId); + void refreshEnvironmentInternal(environmentId, generation); void connectStreamInternal(environmentId, generation); } + function restartEnvironmentInternal(environmentId: string) { + const state = environmentStateInternal(environmentId); + if (!state) { + return; + } + + clearEnvironmentErrorInternal(environmentId); + startEnvironmentInternal({ id: state.id, name: state.name }); + } + + function reconcileEnvironmentsInternal() { + if (!browser || !started) { + return; + } + + const available = environmentStore.available; + const environments = + available.length > 0 + ? available + : [ + { + id: environmentStore.selected?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID, + name: environmentStore.selected?.name ?? 'Local' + } + ]; + const targetIds = new Set(environments.map((environment) => environment.id || LOCAL_DOCKER_ENVIRONMENT_ID)); + + for (const environmentId of Object.keys(_environmentStates)) { + if (!targetIds.has(environmentId)) { + stopEnvironmentInternal(environmentId); + } + } + + for (const environment of environments) { + const environmentId = environment.id || LOCAL_DOCKER_ENVIRONMENT_ID; + const existing = environmentStateInternal(environmentId); + if (!existing) { + _environmentStates = { + ..._environmentStates, + [environmentId]: createEnvironmentStateInternal(environment) + }; + startEnvironmentInternal(environment); + continue; + } + + if (existing.name !== environmentNameInternal(environment)) { + const updatedActivities = (_environmentActivities[environmentId] ?? existing.activities).map((activity) => + activity.sourceEnvironmentName + ? activity + : { + ...activity, + sourceEnvironmentName: environmentNameInternal(environment) + } + ); + _environmentActivities = { + ..._environmentActivities, + [environmentId]: updatedActivities + }; + updateEnvironmentStateInternal(environmentId, (state) => ({ + ...state, + name: environmentNameInternal(environment), + activities: updatedActivities + })); + rebuildActivitiesInternal(); + } + } + } + + function activityEnvironmentIdInternal(activityId: string): string { + const activity = _details[activityId]?.activity ?? _activities.find((item) => item.id === activityId) ?? null; + return sourceEnvironmentIdInternal(activity) || _currentEnvironmentId || LOCAL_DOCKER_ENVIRONMENT_ID; + } + async function loadDetailInternal(activityId: string) { if (_details[activityId] || _detailLoadingIds[activityId]) { return; @@ -287,8 +533,17 @@ function createActivityStore() { _detailLoadingIds = { ..._detailLoadingIds, [activityId]: true }; try { - const detail = await activityService.getActivity(activityId, _currentEnvironmentId, ACTIVITY_DETAIL_LIMIT); - _details = { ..._details, [activityId]: detail }; + const detail = await activityService.getActivity( + activityId, + activityEnvironmentIdInternal(activityId), + ACTIVITY_DETAIL_LIMIT + ); + const environmentId = sourceEnvironmentIdInternal(detail.activity); + const normalized = { + ...detail, + activity: normalizeActivityInternal(detail.activity, environmentId) + }; + _details = { ..._details, [activityId]: normalized }; const nextErrors = { ..._detailErrorIds }; delete nextErrors[activityId]; _detailErrorIds = nextErrors; @@ -327,6 +582,16 @@ function createActivityStore() { setActivityExpanded(activityId, !_expandedActivityIds[activityId]); } + function environmentFailuresInternal(): ActivityEnvironmentFailure[] { + return Object.values(_environmentStates) + .filter((state) => state.streamError) + .map((state) => ({ + environmentId: state.id, + environmentName: state.name, + message: state.errorMessage + })); + } + return { get activities(): Activity[] { return _activities; @@ -344,13 +609,16 @@ function createActivityStore() { return _open; }, get loading(): boolean { - return _loading; + return Object.values(_environmentStates).some((state) => state.loading); }, get connected(): boolean { - return _connected; + return Object.values(_environmentStates).some((state) => state.connected); }, get streamError(): boolean { - return _streamError; + return Object.values(_environmentStates).some((state) => state.streamError); + }, + get environmentFailures(): ActivityEnvironmentFailure[] { + return environmentFailuresInternal(); }, get currentEnvironmentId(): string { return _currentEnvironmentId; @@ -384,17 +652,22 @@ function createActivityStore() { started = true; await environmentStore.ready; - restartForEnvironmentInternal(environmentStore.selected?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID); + _currentEnvironmentId = environmentStore.selected?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID; + reconcileEnvironmentsInternal(); unsubscribeEnvironment = environmentStore.subscribeSelected((environment) => { - restartForEnvironmentInternal(environment?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID); + _currentEnvironmentId = environment?.id ?? LOCAL_DOCKER_ENVIRONMENT_ID; + reconcileEnvironmentsInternal(); }); }, stop: () => { started = false; unsubscribeEnvironment?.(); unsubscribeEnvironment = null; - streamGeneration += 1; - abortStreamInternal(); + + for (const environmentId of Object.keys(_environmentStates)) { + nextGenerationInternal(environmentId); + abortEnvironmentStreamInternal(environmentId); + } }, refresh: () => refreshInternal(), cancelActivity: async (activityId: string) => { @@ -405,20 +678,47 @@ function createActivityStore() { try { // The cancelled status arrives via the stream (mergeActivityInternal); // callers handle success/error toasts. - await activityService.cancelActivity(activityId, _currentEnvironmentId); + await activityService.cancelActivity(activityId, activityEnvironmentIdInternal(activityId)); } finally { const next = { ..._cancellingIds }; delete next[activityId]; _cancellingIds = next; } }, - clearHistory: async () => { - await activityService.clearHistory(_currentEnvironmentId); + clearHistory: async (): Promise => { + reconcileEnvironmentsInternal(); + + let deleted = 0; + let succeeded = 0; + const failed: ActivityEnvironmentFailure[] = []; + const states = Object.values(_environmentStates); + await Promise.all( + states.map(async (state) => { + try { + const result = await activityService.clearHistory(state.id); + deleted += result.deleted ?? 0; + succeeded += 1; + } catch (error) { + failed.push({ + environmentId: state.id, + environmentName: state.name, + message: errorMessageInternal(error) + }); + } + }) + ); + _details = {}; _expandedActivityIds = {}; _detailLoadingIds = {}; _detailErrorIds = {}; await refreshInternal(); + + return { + deleted, + succeeded, + failed + }; }, setFilter: (filter: ActivityFilter) => { _filter = filter; @@ -442,9 +742,12 @@ function createActivityStore() { void loadDetailInternal(activityId); }, retryStream: () => { - _streamError = false; - reconnectAttempt = 0; - restartForEnvironmentInternal(_currentEnvironmentId); + for (const environmentId of Object.keys(_environmentStates)) { + const state = environmentStateInternal(environmentId); + if (state?.streamError) { + restartEnvironmentInternal(environmentId); + } + } }, setActivityExpanded, toggleActivity diff --git a/frontend/src/lib/types/activity.type.ts b/frontend/src/lib/types/activity.type.ts index 8c2a8a571d..3fd0b69ef7 100644 --- a/frontend/src/lib/types/activity.type.ts +++ b/frontend/src/lib/types/activity.type.ts @@ -72,6 +72,18 @@ export interface ActivityClearHistoryResult { deleted: number; } +export interface ActivityEnvironmentFailure { + environmentId: string; + environmentName: string; + message?: string; +} + +export interface ActivityClearHistorySummary { + deleted: number; + succeeded: number; + failed: ActivityEnvironmentFailure[]; +} + export interface ActivityStreamEvent { type: 'snapshot' | 'activity' | 'message' | 'heartbeat'; activityId?: string; diff --git a/tests/spec/activity-center.spec.ts b/tests/spec/activity-center.spec.ts index ccf1d7a38c..4fd42aeee8 100644 --- a/tests/spec/activity-center.spec.ts +++ b/tests/spec/activity-center.spec.ts @@ -1,4 +1,157 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Locator, type Page, type Route } from '@playwright/test'; + +type MockEnvironment = { + id: string; + name: string; + apiUrl: string; + status: 'online' | 'standby' | 'offline' | 'error' | 'pending'; + enabled: boolean; + isEdge: boolean; +}; + +type MockActivity = { + id: string; + environmentId: string; + sourceEnvironmentId?: string; + sourceEnvironmentName?: string; + type: string; + status: string; + resourceType?: string; + resourceId?: string; + resourceName?: string; + latestMessage?: string; + startedAt: string; + createdAt: string; + updatedAt?: string; +}; + +const localEnvironment: MockEnvironment = { + id: '0', + name: 'Local', + apiUrl: 'unix:///var/run/docker.sock', + status: 'online', + enabled: true, + isEdge: false +}; + +const remoteEnvironment: MockEnvironment = { + id: 'remote-activity-test', + name: 'Remote Lab', + apiUrl: 'https://remote.example.invalid', + status: 'offline', + enabled: false, + isEdge: false +}; + +function paginated(data: T[]) { + return { + success: true, + data, + pagination: { + totalPages: 1, + totalItems: data.length, + currentPage: 1, + itemsPerPage: data.length, + grandTotalItems: data.length + } + }; +} + +function activity( + id: string, + environmentId: string, + sourceEnvironmentName: string, + resourceName: string, + minutesAgo: number +): MockActivity { + const timestamp = new Date(Date.now() - minutesAgo * 60_000).toISOString(); + return { + id, + environmentId, + sourceEnvironmentId: environmentId, + sourceEnvironmentName, + type: 'resource_action', + status: 'success', + resourceType: 'network', + resourceId: resourceName, + resourceName, + latestMessage: `${resourceName} completed`, + startedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp + }; +} + +function activityEnvironmentIdFromPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/environments\/([^/]+)\/activities(?:\/stream)?$/); + return match ? decodeURIComponent(match[1]) : null; +} + +async function preserveLocalEnvironmentSelection(page: Page) { + await page.addInitScript(() => { + localStorage.removeItem('selectedEnvironmentId'); + }); +} + +async function mockEnvironmentList(page: Page, environments: MockEnvironment[]) { + await page.context().route(/\/api\/environments(?:\?.*)?$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(paginated(environments)) + }); + }); +} + +async function mockActivityReads( + page: Page, + activitiesByEnvironment: Record, + failedEnvironmentIds = new Set() +) { + await page + .context() + .route( + /\/api\/environments\/[^/]+\/activities(?:\/stream)?(?:\?.*)?$/, + async (route: Route) => { + const url = new URL(route.request().url()); + const environmentId = activityEnvironmentIdFromPath(url.pathname); + if (!environmentId) { + await route.continue(); + return; + } + + if (failedEnvironmentIds.has(environmentId)) { + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ success: false, message: 'environment unavailable' }) + }); + return; + } + + const activities = activitiesByEnvironment[environmentId] ?? []; + if (url.pathname.endsWith('/stream')) { + await route.fulfill({ + status: 200, + contentType: 'application/x-json-stream', + body: + JSON.stringify({ + type: 'snapshot', + activities, + timestamp: new Date().toISOString() + }) + '\n' + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(paginated(activities)) + }); + } + ); +} function extractActivityId(value: unknown): string | undefined { if (!value || typeof value !== 'object') return undefined; @@ -76,7 +229,127 @@ async function openActivityCenter(page: Page) { return activityCenter; } +function activityRow(activityCenter: Locator, text: string) { + return activityCenter + .locator('button[aria-label="Activity Center"]') + .filter({ hasText: text }) + .first(); +} + +function waitForActivityList(page: Page, environmentId: string) { + return page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + response.request().method() === 'GET' && + url.pathname === `/api/environments/${encodeURIComponent(environmentId)}/activities` + ); + }); +} + test.describe('Activity Center', () => { + test('shows activity from every configured environment', async ({ page }) => { + await preserveLocalEnvironmentSelection(page); + await mockEnvironmentList(page, [localEnvironment, remoteEnvironment]); + await mockActivityReads(page, { + '0': [activity('local-activity', '0', 'Local', 'local-network', 5)], + 'remote-activity-test': [ + activity('remote-activity', 'remote-activity-test', 'Remote Lab', 'remote-network', 1) + ] + }); + + const localActivityList = waitForActivityList(page, '0'); + const remoteActivityList = waitForActivityList(page, 'remote-activity-test'); + await page.goto('/dashboard'); + await Promise.all([localActivityList, remoteActivityList]); + await page.waitForLoadState('load'); + await page.waitForLoadState('networkidle'); + + const activityCenter = await openActivityCenter(page); + await activityCenter.getByRole('button', { name: 'Completed' }).click(); + + await expect(activityRow(activityCenter, 'local-network')).toBeVisible(); + await expect(activityRow(activityCenter, 'remote-network')).toBeVisible(); + await expect(activityCenter.getByText('Local').first()).toBeVisible(); + await expect(activityCenter.getByText('Remote Lab').first()).toBeVisible(); + await expect(page.getByRole('button', { name: /Local/ }).first()).toBeVisible(); + }); + + test('keeps reachable activity visible when a configured environment fails', async ({ page }) => { + await preserveLocalEnvironmentSelection(page); + await mockEnvironmentList(page, [localEnvironment, remoteEnvironment]); + await mockActivityReads( + page, + { + '0': [activity('local-activity', '0', 'Local', 'local-network', 5)] + }, + new Set(['remote-activity-test']) + ); + + const localActivityList = waitForActivityList(page, '0'); + const remoteActivityList = waitForActivityList(page, 'remote-activity-test'); + await page.goto('/dashboard'); + await Promise.all([localActivityList, remoteActivityList]); + await page.waitForLoadState('load'); + await page.waitForLoadState('networkidle'); + + const activityCenter = await openActivityCenter(page); + await activityCenter.getByRole('button', { name: 'Completed' }).click(); + + await expect(activityRow(activityCenter, 'local-network')).toBeVisible(); + await expect(activityCenter.getByText('Could not load activity from Remote Lab')).toBeVisible(); + }); + + test('clears history for every configured environment and reports partial failures', async ({ + page + }) => { + await preserveLocalEnvironmentSelection(page); + await mockEnvironmentList(page, [localEnvironment, remoteEnvironment]); + await mockActivityReads(page, { + '0': [activity('local-activity', '0', 'Local', 'local-network', 5)], + 'remote-activity-test': [ + activity('remote-activity', 'remote-activity-test', 'Remote Lab', 'remote-network', 1) + ] + }); + + const deletedEnvironments: string[] = []; + await page.route(/\/api\/environments\/[^/]+\/activities\/history$/, async (route) => { + const url = new URL(route.request().url()); + const match = url.pathname.match(/^\/api\/environments\/([^/]+)\/activities\/history$/); + const environmentId = match ? decodeURIComponent(match[1]) : ''; + deletedEnvironments.push(environmentId); + + if (environmentId === 'remote-activity-test') { + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ success: false, message: 'environment unavailable' }) + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { deleted: 2 } }) + }); + }); + + const localActivityList = waitForActivityList(page, '0'); + const remoteActivityList = waitForActivityList(page, 'remote-activity-test'); + await page.goto('/dashboard'); + await Promise.all([localActivityList, remoteActivityList]); + await page.waitForLoadState('load'); + + const activityCenter = await openActivityCenter(page); + await activityCenter.getByRole('button', { name: 'Clear history' }).click(); + await page.getByRole('button', { name: 'Clear History', exact: true }).last().click(); + + await expect.poll(() => deletedEnvironments.sort()).toEqual(['0', 'remote-activity-test']); + await expect( + page.getByText('Activity history partially cleared. Succeeded for 1. Failed for Remote Lab.') + ).toBeVisible(); + }); + test('shows completed activity details for UI-triggered work', async ({ page }) => { const networkName = `e2e-activity-network-${Date.now()}`; let networkId: string | undefined; From 75f96a96655305d9812ac0d93f0ff7579745e030 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sat, 30 May 2026 16:56:49 -0500 Subject: [PATCH 23/40] feat: support for federated credentials for workflow automation (#2769) --- .vscode/settings.json | 4 +- backend/api/api.go | 4 + backend/api/handlers/federated.go | 335 +++++++ backend/api/playwright.go | 30 + .../bootstrap/playwright_router_bootstrap.go | 2 +- .../internal/bootstrap/router_bootstrap.go | 5 + .../internal/bootstrap/services_bootstrap.go | 2 + backend/internal/common/errors.go | 50 + backend/internal/models/event.go | 11 +- .../internal/models/federated_credential.go | 33 + .../internal/models/federated_token_replay.go | 14 + backend/internal/models/settings.go | 2 + backend/internal/models/user.go | 1 + backend/internal/models/user_session.go | 25 +- backend/internal/services/auth_service.go | 78 +- backend/internal/services/event_service.go | 5 +- .../services/federated_credential_service.go | 873 ++++++++++++++++++ .../federated_credential_service_test.go | 363 ++++++++ .../internal/services/playwright_service.go | 71 +- backend/internal/services/role_service.go | 37 +- backend/internal/services/session_service.go | 21 + backend/internal/services/user_service.go | 4 +- backend/pkg/authz/catalog.go | 7 + backend/pkg/authz/permissions.go | 8 + .../056_add_federated_credentials.down.sql | 13 + .../056_add_federated_credentials.up.sql | 42 + .../056_add_federated_credentials.down.sql | 13 + .../056_add_federated_credentials.up.sql | 45 + cli/internal/ci/ci_detect.go | 116 +++ cli/internal/ci/ci_detect_test.go | 64 ++ cli/internal/client/client.go | 11 + cli/internal/config/config.go | 5 + cli/internal/types/config.go | 2 + cli/internal/types/endpoints.go | 27 +- cli/pkg/auth/federated.go | 233 +++++ cli/pkg/config/cmd.go | 29 +- frontend/messages/en.json | 58 ++ .../federated-credential-form-sheet.svelte | 309 +++++++ frontend/src/lib/query/query-keys.ts | 4 + .../services/federated-credential-service.ts | 30 + frontend/src/lib/types/auth.ts | 44 + .../settings/authentication/+page.svelte | 617 +++++++------ .../(app)/settings/authentication/+page.ts | 45 +- .../federated-credential-table.svelte | 290 ++++++ .../federated-credentials-tab.svelte | 195 ++++ tests/setup/compose-proxy.yaml | 4 +- tests/spec/cli.spec.ts | 128 +++ tests/utils/oidc.util.ts | 86 ++ types/federated/federated.go | 93 ++ 49 files changed, 4142 insertions(+), 346 deletions(-) create mode 100644 backend/api/handlers/federated.go create mode 100644 backend/internal/models/federated_credential.go create mode 100644 backend/internal/models/federated_token_replay.go create mode 100644 backend/internal/services/federated_credential_service.go create mode 100644 backend/internal/services/federated_credential_service_test.go create mode 100644 backend/resources/migrations/postgres/056_add_federated_credentials.down.sql create mode 100644 backend/resources/migrations/postgres/056_add_federated_credentials.up.sql create mode 100644 backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql create mode 100644 backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql create mode 100644 cli/internal/ci/ci_detect.go create mode 100644 cli/internal/ci/ci_detect_test.go create mode 100644 cli/pkg/auth/federated.go create mode 100644 frontend/src/lib/components/sheets/federated-credential-form-sheet.svelte create mode 100644 frontend/src/lib/services/federated-credential-service.ts create mode 100644 frontend/src/routes/(app)/settings/authentication/federated-credential-table.svelte create mode 100644 frontend/src/routes/(app)/settings/authentication/federated-credentials-tab.svelte create mode 100644 tests/utils/oidc.util.ts create mode 100644 types/federated/federated.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 3807e18171..6c72a52a54 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,11 +6,11 @@ "scminput": false, "typescript": true }, - "typescript.tsdk": "frontend/node_modules/typescript/lib", "go.buildTags": "playwright,buildables", "prettier.documentSelectors": ["**/*.svelte"], "tasks.statusbar.default.hide": true, "tasks.statusbar.limit": 8, "github.copilot.chat.summarizeAgentConversationHistory.enabled": false, - "snyk.advanced.autoSelectOrganization": false + "snyk.advanced.autoSelectOrganization": false, + "js/ts.tsdk.path": "frontend/node_modules/typescript/lib" } diff --git a/backend/api/api.go b/backend/api/api.go index 131fbdc842..0be9a4a0d4 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -144,6 +144,7 @@ type Services struct { Auth *services.AuthService Oidc *services.OidcService ApiKey *services.ApiKeyService + Federated *services.FederatedCredentialService AppImages *services.ApplicationImagesService Project *services.ProjectService Event *services.EventService @@ -317,6 +318,7 @@ func registerHandlers(api huma.API, svc *Services) { var authSvc *services.AuthService var oidcSvc *services.OidcService var apiKeySvc *services.ApiKeyService + var federatedSvc *services.FederatedCredentialService var appImagesSvc *services.ApplicationImagesService var projectSvc *services.ProjectService var eventSvc *services.EventService @@ -358,6 +360,7 @@ func registerHandlers(api huma.API, svc *Services) { authSvc = svc.Auth oidcSvc = svc.Oidc apiKeySvc = svc.ApiKey + federatedSvc = svc.Federated appImagesSvc = svc.AppImages projectSvc = svc.Project eventSvc = svc.Event @@ -396,6 +399,7 @@ func registerHandlers(api huma.API, svc *Services) { handlers.RegisterHealth(api) handlers.RegisterAuth(api, userSvc, authSvc, oidcSvc) handlers.RegisterApiKeys(api, apiKeySvc) + handlers.RegisterFederatedCredentials(api, federatedSvc) handlers.RegisterRoles(api, roleSvc) handlers.RegisterAppImages(api, appImagesSvc) handlers.RegisterUsers(api, userSvc, authSvc) diff --git a/backend/api/handlers/federated.go b/backend/api/handlers/federated.go new file mode 100644 index 0000000000..3e6c6bec95 --- /dev/null +++ b/backend/api/handlers/federated.go @@ -0,0 +1,335 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/types/base" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" + "github.com/labstack/echo/v4" +) + +// FederatedCredentialHandler provides Huma-based federated credential +// management endpoints. +type FederatedCredentialHandler struct { + federatedCredentialService *services.FederatedCredentialService +} + +type federatedTokenExchangeError struct { + Error string `json:"error"` //nolint:tagliatelle // RFC 6749 wire shape is snake_case. + ErrorDescription string `json:"error_description,omitempty"` //nolint:tagliatelle // RFC 6749 wire shape is snake_case. +} + +type ListFederatedCredentialsInput struct { + Search string `query:"search" doc:"Search query for filtering by name, issuer, or subject"` + Sort string `query:"sort" doc:"Column to sort by"` + Order string `query:"order" default:"asc" doc:"Sort direction (asc or desc)"` + Start int `query:"start" default:"0" doc:"Start index for pagination"` + Limit int `query:"limit" default:"20" doc:"Number of items per page"` +} + +type ListFederatedCredentialsOutput struct { + Body base.Paginated[federatedtypes.FederatedCredential] +} + +type CreateFederatedCredentialInput struct { + Body federatedtypes.CreateFederatedCredential +} + +type CreateFederatedCredentialOutput struct { + Body base.ApiResponse[federatedtypes.FederatedCredential] +} + +type GetFederatedCredentialInput struct { + ID string `path:"id" doc:"Federated credential ID"` +} + +type GetFederatedCredentialOutput struct { + Body base.ApiResponse[federatedtypes.FederatedCredential] +} + +type UpdateFederatedCredentialInput struct { + ID string `path:"id" doc:"Federated credential ID"` + Body federatedtypes.UpdateFederatedCredential +} + +type UpdateFederatedCredentialOutput struct { + Body base.ApiResponse[federatedtypes.FederatedCredential] +} + +type DeleteFederatedCredentialInput struct { + ID string `path:"id" doc:"Federated credential ID"` +} + +type DeleteFederatedCredentialOutput struct { + Body base.ApiResponse[base.MessageResponse] +} + +// RegisterFederatedTokenExchange registers the public RFC 8693 token exchange +// endpoint. It intentionally uses Echo because the standard requires form +// encoding while the rest of Arcane's Huma API is JSON-first. +func RegisterFederatedTokenExchange(g *echo.Group, federatedCredentialService *services.FederatedCredentialService) { + g.POST("/auth/federated/token", func(c echo.Context) error { + if federatedCredentialService == nil { + return c.JSON(http.StatusInternalServerError, federatedTokenExchangeError{ + Error: "server_error", + ErrorDescription: "service not available", + }) + } + if err := c.Request().ParseForm(); err != nil { + return c.JSON(http.StatusBadRequest, federatedTokenExchangeError{ + Error: "invalid_request", + ErrorDescription: "invalid token exchange request", + }) + } + + form := c.Request().Form + resp, err := federatedCredentialService.ExchangeToken(c.Request().Context(), federatedtypes.TokenExchangeRequest{ + GrantType: form.Get("grant_type"), + SubjectToken: form.Get("subject_token"), + SubjectTokenType: form.Get("subject_token_type"), + Audience: form.Get("audience"), + Scope: form.Get("scope"), + RequestedTokenType: form.Get("requested_token_type"), + }) + if err != nil { + return writeFederatedTokenExchangeErrorInternal(c, err) + } + return c.JSON(http.StatusOK, resp) + }) +} + +// RegisterFederatedCredentials registers federated credential management routes. +func RegisterFederatedCredentials(api huma.API, federatedCredentialService *services.FederatedCredentialService) { + h := &FederatedCredentialHandler{ + federatedCredentialService: federatedCredentialService, + } + + huma.Register(api, huma.Operation{ + OperationID: "list-federated-credentials", + Method: http.MethodGet, + Path: "/federated-credentials", + Summary: "List federated credentials", + Description: "Get a paginated list of workload identity federation trust rules", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermFederatedList), + }, h.ListFederatedCredentials) + + huma.Register(api, huma.Operation{ + OperationID: "create-federated-credential", + Method: http.MethodPost, + Path: "/federated-credentials", + Summary: "Create a federated credential", + Description: "Create a workload identity federation trust rule", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.CreateFederatedCredential) + + huma.Register(api, huma.Operation{ + OperationID: "get-federated-credential", + Method: http.MethodGet, + Path: "/federated-credentials/{id}", + Summary: "Get a federated credential", + Description: "Get details of a workload identity federation trust rule", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermFederatedRead), + }, h.GetFederatedCredential) + + huma.Register(api, huma.Operation{ + OperationID: "update-federated-credential", + Method: http.MethodPut, + Path: "/federated-credentials/{id}", + Summary: "Update a federated credential", + Description: "Update a workload identity federation trust rule", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.UpdateFederatedCredential) + + huma.Register(api, huma.Operation{ + OperationID: "delete-federated-credential", + Method: http.MethodDelete, + Path: "/federated-credentials/{id}", + Summary: "Delete a federated credential", + Description: "Delete a workload identity federation trust rule and its service user", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.DeleteFederatedCredential) +} + +func (h *FederatedCredentialHandler) ListFederatedCredentials(ctx context.Context, input *ListFederatedCredentialsInput) (*ListFederatedCredentialsOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + credentials, paginationResp, err := h.federatedCredentialService.List(ctx, pagination.QueryParams{ + SearchQuery: pagination.SearchQuery{Search: input.Search}, + SortParams: pagination.SortParams{ + Sort: input.Sort, + Order: pagination.SortOrder(input.Order), + }, + PaginationParams: pagination.PaginationParams{ + Start: input.Start, + Limit: input.Limit, + }, + }) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list federated credentials") + } + + return &ListFederatedCredentialsOutput{ + Body: base.Paginated[federatedtypes.FederatedCredential]{ + Success: true, + Data: credentials, + Pagination: base.PaginationResponse{ + TotalPages: paginationResp.TotalPages, + TotalItems: paginationResp.TotalItems, + CurrentPage: paginationResp.CurrentPage, + ItemsPerPage: paginationResp.ItemsPerPage, + GrandTotalItems: paginationResp.GrandTotalItems, + }, + }, + }, nil +} + +func (h *FederatedCredentialHandler) CreateFederatedCredential(ctx context.Context, input *CreateFederatedCredentialInput) (*CreateFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + credential, err := h.federatedCredentialService.Create(ctx, user.ID, input.Body) + if err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &CreateFederatedCredentialOutput{ + Body: base.ApiResponse[federatedtypes.FederatedCredential]{ + Success: true, + Data: *credential, + }, + }, nil +} + +func (h *FederatedCredentialHandler) GetFederatedCredential(ctx context.Context, input *GetFederatedCredentialInput) (*GetFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + credential, err := h.federatedCredentialService.Get(ctx, input.ID) + if err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &GetFederatedCredentialOutput{ + Body: base.ApiResponse[federatedtypes.FederatedCredential]{ + Success: true, + Data: *credential, + }, + }, nil +} + +func (h *FederatedCredentialHandler) UpdateFederatedCredential(ctx context.Context, input *UpdateFederatedCredentialInput) (*UpdateFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + credential, err := h.federatedCredentialService.Update(ctx, user.ID, input.ID, input.Body) + if err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &UpdateFederatedCredentialOutput{ + Body: base.ApiResponse[federatedtypes.FederatedCredential]{ + Success: true, + Data: *credential, + }, + }, nil +} + +func (h *FederatedCredentialHandler) DeleteFederatedCredential(ctx context.Context, input *DeleteFederatedCredentialInput) (*DeleteFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if err := h.federatedCredentialService.Delete(ctx, input.ID); err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &DeleteFederatedCredentialOutput{ + Body: base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: base.MessageResponse{ + Message: "Federated credential deleted successfully", + }, + }, + }, nil +} + +func writeFederatedTokenExchangeErrorInternal(c echo.Context, err error) error { + var code string + description := "token exchange rejected" + + switch { + case common.IsErrorFederatedCredentialInvalidRequest(err): + code = "invalid_request" + description = "invalid token exchange request" + case common.IsErrorFederatedCredentialInvalidGrant(err), + common.IsErrorFederatedCredentialNotFound(err): + code = "invalid_grant" + case common.IsErrorFederatedCredentialInvalid(err): + code = "invalid_request" + default: + code = "server_error" + description = "token exchange failed" + } + + return c.JSON(http.StatusBadRequest, federatedTokenExchangeError{ + Error: code, + ErrorDescription: description, + }) +} + +func federatedCredentialManagementErrorInternal(err error) error { + switch { + case common.IsErrorFederatedCredentialNotFound(err): + return huma.Error404NotFound("federated credential not found") + case common.IsErrorFederatedCredentialInvalid(err), + common.IsErrorFederatedCredentialInvalidRequest(err): + return huma.Error400BadRequest("invalid federated credential") + case common.IsErrorFederatedCredentialPermissionEscalation(err): + return huma.Error403Forbidden("permission denied") + default: + return huma.Error500InternalServerError("federated credential operation failed") + } +} diff --git a/backend/api/playwright.go b/backend/api/playwright.go index ee2f0b8282..b1780fd6f9 100644 --- a/backend/api/playwright.go +++ b/backend/api/playwright.go @@ -24,12 +24,21 @@ func SetupPlaywrightRoutes(api *echo.Group, playwrightService *services.Playwrig playwright.POST("/create-test-api-keys", playwrightHandler.CreateTestApiKeysHandler) playwright.POST("/delete-test-api-keys", playwrightHandler.DeleteTestApiKeysHandler) + playwright.POST("/create-test-federated-credential", playwrightHandler.CreateTestFederatedCredentialHandler) } type CreateTestApiKeysRequest struct { Count int `json:"count"` } +type CreateTestFederatedCredentialRequest struct { + IssuerURL string `json:"issuerUrl"` + Audiences []string `json:"audiences"` + Subject string `json:"subject"` + RoleID string `json:"roleId"` + TokenTTLSeconds int `json:"tokenTtlSeconds"` +} + func (ph *PlaywrightHandler) CreateTestApiKeysHandler(c echo.Context) error { var req CreateTestApiKeysRequest if err := c.Bind(&req); err != nil { @@ -55,3 +64,24 @@ func (ph *PlaywrightHandler) DeleteTestApiKeysHandler(c echo.Context) error { return c.NoContent(http.StatusNoContent) } + +func (ph *PlaywrightHandler) CreateTestFederatedCredentialHandler(c echo.Context) error { + var req CreateTestFederatedCredentialRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]any{"error": "invalid request body"}) + } + + credentialID, err := ph.PlaywrightService.CreateTestFederatedCredential( + c.Request().Context(), + req.IssuerURL, + req.Audiences, + req.Subject, + req.RoleID, + req.TokenTTLSeconds, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, map[string]any{"credential": map[string]string{"id": credentialID}}) +} diff --git a/backend/internal/bootstrap/playwright_router_bootstrap.go b/backend/internal/bootstrap/playwright_router_bootstrap.go index 57629c65bc..94d1cf4101 100644 --- a/backend/internal/bootstrap/playwright_router_bootstrap.go +++ b/backend/internal/bootstrap/playwright_router_bootstrap.go @@ -13,7 +13,7 @@ import ( func init() { registerPlaywrightRoutes = []func(apiGroup *echo.Group, services *Services){ func(apiGroup *echo.Group, svc *Services) { - playwrightService := services.NewPlaywrightService(svc.ApiKey, svc.User) + playwrightService := services.NewPlaywrightService(svc.ApiKey, svc.User, svc.Federated) if playwrightService == nil { slog.Warn("Playwright service not available, skipping playwright routes") return diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 0815511696..9bf7b6fa00 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -158,6 +158,9 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) "/api/oidc/callback", }, 5, 5, )) + apiGroup.Use(middleware.PerIPRateLimitForPaths( + []string{"/api/auth/federated/token"}, 10, 10, + )) apiGroup.Use(middleware.PerIPRateLimitForPaths( []string{"/api/webhooks/trigger/:token"}, 60, 10, )) @@ -175,6 +178,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) // Register public webhook trigger endpoint before auth middleware (token in URL is the sole auth) api.RegisterWebhookTrigger(apiGroup, appServices.Webhook, handlerAppCtx) //nolint:contextcheck // app lifecycle context is intentionally wrapped for detached activity work. + handlers.RegisterFederatedTokenExchange(apiGroup, appServices.Federated) //nolint:contextcheck // public RFC 8693 form endpoint uses request context. //nolint:contextcheck // Echo middleware reads context from echo.Context.Request().Context(), not a parameter. envProxyMiddleware := middleware.NewEnvProxyMiddlewareWithParam( @@ -191,6 +195,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) Auth: appServices.Auth, Oidc: appServices.Oidc, ApiKey: appServices.ApiKey, + Federated: appServices.Federated, AppImages: appServices.AppImages, Project: appServices.Project, Event: appServices.Event, diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index ec9899c10a..76463db637 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -44,6 +44,7 @@ type Services struct { Version *services.VersionService Notification *services.NotificationService ApiKey *services.ApiKeyService + Federated *services.FederatedCredentialService GitRepository *services.GitRepositoryService GitOpsSync *services.GitOpsSyncService Webhook *services.WebhookService @@ -104,6 +105,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Template = services.NewTemplateService(ctx, db, httpClient, svcs.Settings) svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, svcs.Session, svcs.Role, cfg.JWTSecret, cfg) svcs.Oidc = services.NewOidcService(svcs.Auth, svcs.Settings, cfg, httpClient) + svcs.Federated = services.NewFederatedCredentialService(db, svcs.Auth, svcs.User, svcs.Settings, svcs.Event, httpClient).WithRoleService(svcs.Role) svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings, svcs.Activity) svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings) svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade, svcs.Activity) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 2291483395..9f231fbd1d 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -1778,6 +1778,56 @@ func IsInvalidRoleAssignmentError(err error) bool { return isErrorTypeInternal[*InvalidRoleAssignmentError](err) } +type FederatedCredentialNotFoundError struct{} + +func (e *FederatedCredentialNotFoundError) Error() string { + return "federated credential not found" +} + +func IsErrorFederatedCredentialNotFound(err error) bool { + return isErrorTypeInternal[*FederatedCredentialNotFoundError](err) +} + +type FederatedCredentialInvalidError struct{} + +func (e *FederatedCredentialInvalidError) Error() string { + return "invalid federated credential" +} + +func IsErrorFederatedCredentialInvalid(err error) bool { + return isErrorTypeInternal[*FederatedCredentialInvalidError](err) +} + +type FederatedCredentialInvalidRequestError struct{} + +func (e *FederatedCredentialInvalidRequestError) Error() string { + return "invalid federated token exchange request" +} + +func IsErrorFederatedCredentialInvalidRequest(err error) bool { + return isErrorTypeInternal[*FederatedCredentialInvalidRequestError](err) +} + +type FederatedCredentialInvalidGrantError struct{} + +func (e *FederatedCredentialInvalidGrantError) Error() string { + return "invalid federated token grant" +} + +func IsErrorFederatedCredentialInvalidGrant(err error) bool { + return isErrorTypeInternal[*FederatedCredentialInvalidGrantError](err) +} + +type FederatedCredentialPermissionEscalationError struct{} + +func (e *FederatedCredentialPermissionEscalationError) Error() string { + return "cannot map a federated credential to a role you do not hold" +} + +func IsErrorFederatedCredentialPermissionEscalation(err error) bool { + return isErrorTypeInternal[*FederatedCredentialPermissionEscalationError](err) +} + type OidcMappingNotFoundError struct{} func (e *OidcMappingNotFoundError) Error() string { diff --git a/backend/internal/models/event.go b/backend/internal/models/event.go index a00f840835..09a0703f23 100644 --- a/backend/internal/models/event.go +++ b/backend/internal/models/event.go @@ -66,11 +66,12 @@ const ( EventTypeNetworkDelete EventType = "network.delete" EventTypeNetworkError EventType = "network.error" - EventTypeSystemPrune EventType = "system.prune" - EventTypeUserLogin EventType = "user.login" - EventTypeUserLogout EventType = "user.logout" - EventTypeSystemAutoUpdate EventType = "system.auto_update" - EventTypeSystemUpgrade EventType = "system.upgrade" + EventTypeSystemPrune EventType = "system.prune" + EventTypeUserLogin EventType = "user.login" + EventTypeUserLogout EventType = "user.logout" + EventTypeFederatedExchange EventType = "federated.exchange" + EventTypeSystemAutoUpdate EventType = "system.auto_update" + EventTypeSystemUpgrade EventType = "system.upgrade" EventTypeEnvironmentCreate EventType = "environment.create" EventTypeEnvironmentConnect EventType = "environment.connect" diff --git a/backend/internal/models/federated_credential.go b/backend/internal/models/federated_credential.go new file mode 100644 index 0000000000..ce57dd8dfa --- /dev/null +++ b/backend/internal/models/federated_credential.go @@ -0,0 +1,33 @@ +package models + +import "time" + +const ( + FederatedCredentialMatchExact = "exact" + FederatedCredentialMatchGlob = "glob" +) + +type FederatedCredential struct { + Name string `json:"name" gorm:"column:name;not null" sortable:"true"` + Description *string `json:"description,omitempty" gorm:"column:description"` + Enabled bool `json:"enabled" gorm:"column:enabled;not null;default:false;index" sortable:"true"` + IssuerURL string `json:"issuerUrl" gorm:"column:issuer_url;not null;index" sortable:"true"` + Audiences StringSlice `json:"audiences" gorm:"column:audiences;type:text;not null"` + SubjectClaim string `json:"subjectClaim" gorm:"column:subject_claim;not null;default:'sub'"` + SubjectMatch string `json:"subjectMatch" gorm:"column:subject_match;not null"` + MatchType string `json:"matchType" gorm:"column:match_type;not null;default:'exact'"` + RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id;index"` + IdentityUserID string `json:"identityUserId" gorm:"column:identity_user_id;not null;index"` + TokenTTLSeconds int `json:"tokenTtlSeconds" gorm:"column:token_ttl_seconds;not null;default:900"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty" gorm:"column:last_used_at" sortable:"true"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" gorm:"column:expires_at" sortable:"true"` + IdentityUser *User `json:"identityUser,omitempty" gorm:"foreignKey:IdentityUserID;constraint:OnDelete:CASCADE"` + Role *Role `json:"role,omitempty" gorm:"foreignKey:RoleID;constraint:OnDelete:RESTRICT"` + Environment *Environment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID;constraint:OnDelete:SET NULL"` + BaseModel +} + +func (FederatedCredential) TableName() string { + return "federated_credentials" +} diff --git a/backend/internal/models/federated_token_replay.go b/backend/internal/models/federated_token_replay.go new file mode 100644 index 0000000000..497f582f65 --- /dev/null +++ b/backend/internal/models/federated_token_replay.go @@ -0,0 +1,14 @@ +package models + +import "time" + +type FederatedTokenReplay struct { + TokenHash string `json:"-" gorm:"column:token_hash;not null;uniqueIndex"` + IssuerURL string `json:"issuerUrl" gorm:"column:issuer_url;not null;index"` + ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` + BaseModel +} + +func (FederatedTokenReplay) TableName() string { + return "federated_token_replays" +} diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 657a86a935..307c3244bf 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -174,6 +174,8 @@ type Settings struct { // API Keys category (admin management page - no actual settings) ApiKeysCategoryPlaceholder SettingVariable `key:"apiKeysCategory,internal" meta:"label=API Keys;type=internal;keywords=api,keys,tokens,authentication,access,programmatic,integration;category=apikeys;description=Manage API keys for programmatic access" catmeta:"id=apikeys;title=API Keys;icon=apikey;url=/settings/api-keys;description=Create and manage API keys for programmatic access to Arcane"` + FederatedCredentialsCategoryPlaceholder SettingVariable `key:"federatedCredentialsCategory,internal" meta:"label=Federated Credentials;type=internal;keywords=federated,credentials,workload,identity,oidc,token exchange,ci,github,gitlab;category=authentication;description=Manage workload identity federation credentials"` + // Webhooks category (management page - no actual settings) WebhooksCategoryPlaceholder SettingVariable `key:"webhooksCategory,internal" meta:"label=Webhooks;type=internal;keywords=webhooks,trigger,inbound,http,container,stack,gitops,updater,automation,ci,cd;category=webhooks;description=Manage inbound webhooks to trigger updates" catmeta:"id=webhooks;title=Webhooks;icon=globe;url=/settings/webhooks;description=Create and manage inbound webhooks to trigger container, stack, or GitOps updates"` diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 2669f53b98..10f54fea8c 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -13,6 +13,7 @@ type User struct { LastLogin *time.Time `json:"lastLogin,omitempty" gorm:"column:last_login" sortable:"true"` Locale *string `json:"locale,omitempty" gorm:"column:locale"` RequiresPasswordChange bool `json:"requiresPasswordChange" gorm:"column:requires_password_change"` + IsServiceAccount bool `json:"isServiceAccount" gorm:"column:is_service_account;not null;default:false"` // OIDC provider tokens OidcAccessToken *string `json:"-" gorm:"type:text"` diff --git a/backend/internal/models/user_session.go b/backend/internal/models/user_session.go index 3b6d155d73..086248baaa 100644 --- a/backend/internal/models/user_session.go +++ b/backend/internal/models/user_session.go @@ -2,16 +2,25 @@ package models import "time" +const ( + UserSessionSourceLocal = "local" + UserSessionSourceOidc = "oidc" + UserSessionSourceFederated = "federated" +) + type UserSession struct { BaseModel - UserID string `json:"userId" gorm:"column:user_id;not null;index"` - User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` - RefreshTokenHash string `json:"-" gorm:"column:refresh_token_hash;not null;uniqueIndex"` - UserAgent *string `json:"userAgent,omitempty" gorm:"column:user_agent"` - IPAddress *string `json:"ipAddress,omitempty" gorm:"column:ip_address"` - LastUsedAt time.Time `json:"lastUsedAt" gorm:"column:last_used_at;not null"` - ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` - RevokedAt *time.Time `json:"revokedAt,omitempty" gorm:"column:revoked_at"` + UserID string `json:"userId" gorm:"column:user_id;not null;index"` + User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` + RefreshTokenHash string `json:"-" gorm:"column:refresh_token_hash;not null;uniqueIndex"` + UserAgent *string `json:"userAgent,omitempty" gorm:"column:user_agent"` + IPAddress *string `json:"ipAddress,omitempty" gorm:"column:ip_address"` + Source string `json:"source,omitempty" gorm:"column:source"` + FederatedCredentialID *string `json:"federatedCredentialId,omitempty" gorm:"column:federated_credential_id;index"` + FederatedCredential *FederatedCredential `json:"federatedCredential,omitempty" gorm:"foreignKey:FederatedCredentialID;constraint:OnDelete:SET NULL"` + LastUsedAt time.Time `json:"lastUsedAt" gorm:"column:last_used_at;not null"` + ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` + RevokedAt *time.Time `json:"revokedAt,omitempty" gorm:"column:revoked_at"` } func (UserSession) TableName() string { diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 722e8e1377..45ef7078f3 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -45,12 +45,14 @@ type AuthSettings struct { type userClaims struct { jwt.RegisteredClaims - SessionID string `json:"sid,omitempty"` - UserID string `json:"user_id"` - Username string `json:"username"` - Email string `json:"email,omitempty"` - DisplayName string `json:"display_name,omitempty"` - AppVersion string `json:"app_version,omitempty"` + SessionID string `json:"sid,omitempty"` + UserID string `json:"user_id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + AppVersion string `json:"app_version,omitempty"` + TokenType string `json:"token_type,omitempty"` + FederatedCredentialID string `json:"federated_credential_id,omitempty"` } type refreshClaims struct { @@ -890,6 +892,70 @@ func (s *AuthService) buildTokenPairInternal(ctx context.Context, user *models.U }, nil } +func (s *AuthService) IssueFederatedToken(ctx context.Context, user *models.User, credentialID string, ttlSeconds int) (*TokenPair, error) { + if s.sessionService == nil { + return nil, &common.SessionServiceUnavailableError{} + } + if user == nil { + return nil, ErrUserNotFound + } + + ttlSeconds = clampFederatedTokenTTLSecondsInternal(ttlSeconds) + now := time.Now() + accessTokenExpiry := now.Add(time.Duration(ttlSeconds) * time.Second) + + session, err := s.sessionService.CreateFederatedSession(ctx, user.ID, accessTokenExpiry, credentialID) + if err != nil { + return nil, err + } + + claims := userClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: user.ID, + Subject: "access", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(accessTokenExpiry), + }, + SessionID: session.ID, + UserID: user.ID, + Username: user.Username, + AppVersion: config.Version, + TokenType: models.UserSessionSourceFederated, + FederatedCredentialID: credentialID, + } + + if user.Email != nil { + claims.Email = *user.Email + } + if user.DisplayName != nil { + claims.DisplayName = *user.DisplayName + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessTokenString, err := accessToken.SignedString(s.jwtSecret) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessTokenString, + ExpiresAt: accessTokenExpiry, + }, nil +} + +func clampFederatedTokenTTLSecondsInternal(ttlSeconds int) int { + if ttlSeconds <= 0 { + return 900 + } + if ttlSeconds < 60 { + return 60 + } + if ttlSeconds > 3600 { + return 3600 + } + return ttlSeconds +} + func validateSessionActiveInternal(session *models.UserSession) error { if session == nil { return ErrInvalidToken diff --git a/backend/internal/services/event_service.go b/backend/internal/services/event_service.go index 3b8c063552..7f30f0c70a 100644 --- a/backend/internal/services/event_service.go +++ b/backend/internal/services/event_service.go @@ -636,8 +636,9 @@ var eventDefinitions = map[models.EventType]struct { models.EventTypeSystemAutoUpdate: {"System auto-update completed", "System auto-update process has completed", models.EventSeverityInfo}, models.EventTypeSystemUpgrade: {"System upgrade completed", "System upgrade process has completed", models.EventSeverityInfo}, - models.EventTypeUserLogin: {"User logged in: %s", "User '%s' has logged in", models.EventSeverityInfo}, - models.EventTypeUserLogout: {"User logged out: %s", "User '%s' has logged out", models.EventSeverityInfo}, + models.EventTypeUserLogin: {"User logged in: %s", "User '%s' has logged in", models.EventSeverityInfo}, + models.EventTypeUserLogout: {"User logged out: %s", "User '%s' has logged out", models.EventSeverityInfo}, + models.EventTypeFederatedExchange: {"Federated credential exchange: %s", "Federated credential exchange for '%s'", models.EventSeverityInfo}, } func (s *EventService) toEventDto(e *models.Event) *event.Event { diff --git a/backend/internal/services/federated_credential_service.go b/backend/internal/services/federated_credential_service.go new file mode 100644 index 0000000000..9c0bce591e --- /dev/null +++ b/backend/internal/services/federated_credential_service.go @@ -0,0 +1,873 @@ +package services + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "golang.org/x/sync/singleflight" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" + "github.com/getarcaneapp/arcane/backend/pkg/utils/jwtclaims" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" +) + +const ( + federatedCredentialLastUsedWriteWindow = 5 * time.Minute + defaultFederatedSubjectClaim = "sub" +) + +type FederatedCredentialService struct { + db *database.DB + authService *AuthService + userService *UserService + settingsService *SettingsService + eventService *EventService + roleService *RoleService + httpClient *http.Client + providerMu sync.RWMutex + providers map[string]*oidc.Provider + providerGroup singleflight.Group +} + +func NewFederatedCredentialService( + db *database.DB, + authService *AuthService, + userService *UserService, + settingsService *SettingsService, + eventService *EventService, + httpClient *http.Client, +) *FederatedCredentialService { + if httpClient == nil { + httpClient = httpx.NewHTTPClientWithTimeout(15 * time.Second) + } + + return &FederatedCredentialService{ + db: db, + authService: authService, + userService: userService, + settingsService: settingsService, + eventService: eventService, + httpClient: httpClient, + providers: make(map[string]*oidc.Provider), + } +} + +func (s *FederatedCredentialService) WithRoleService(roleService *RoleService) *FederatedCredentialService { + s.roleService = roleService + return s +} + +func (s *FederatedCredentialService) ExchangeToken(ctx context.Context, req federatedtypes.TokenExchangeRequest) (*federatedtypes.FederatedTokenResponse, error) { + issuer, subject, audiences := unverifiedTokenExchangeMetadataInternal(req.SubjectToken) + logResult := "failure" + logReason := "" + var matchedCredential *models.FederatedCredential + var matchedUser *models.User + defer func() { + s.logExchangeInternal(ctx, logResult, logReason, issuer, subject, audiences, matchedCredential, matchedUser) + }() + + if err := validateTokenExchangeRequestInternal(req); err != nil { + logReason = "invalid_request" + return nil, err + } + if issuer == "" { + logReason = "missing_issuer" + return nil, &common.FederatedCredentialInvalidGrantError{} + } + + credentials, err := s.listEnabledCredentialsForIssuerInternal(ctx, issuer) + if err != nil { + logReason = "credential_lookup_failed" + return nil, err + } + if len(credentials) == 0 { + logReason = "issuer_not_allowed" + return nil, &common.FederatedCredentialInvalidGrantError{} + } + + verifiedToken, verifiedClaims, err := s.verifySubjectTokenInternal(ctx, issuer, req.SubjectToken) + if err != nil { + logReason = "token_verification_failed" + return nil, fmt.Errorf("%w: %w", &common.FederatedCredentialInvalidGrantError{}, err) + } + if subject == "" { + subject = stringClaimByPathInternal(verifiedClaims, defaultFederatedSubjectClaim) + } + if len(audiences) == 0 { + audiences = append([]string{}, verifiedToken.Audience...) + } + + credential := selectMatchingCredentialInternal(credentials, verifiedToken.Audience, verifiedClaims) + if credential == nil { + logReason = "no_matching_credential" + return nil, &common.FederatedCredentialInvalidGrantError{} + } + matchedCredential = credential + if err := s.recordTokenReplayGuardInternal(ctx, issuer, req.SubjectToken, verifiedClaims, verifiedToken.Expiry); err != nil { + logReason = "token_replay_rejected" + return nil, err + } + + user, err := s.userService.GetUserByID(ctx, credential.IdentityUserID) + if err != nil { + logReason = "identity_user_missing" + return nil, fmt.Errorf("%w: %w", &common.FederatedCredentialInvalidGrantError{}, err) + } + matchedUser = user + + tokenPair, err := s.authService.IssueFederatedToken(ctx, user, credential.ID, credential.TokenTTLSeconds) + if err != nil { + logReason = "token_issue_failed" + return nil, err + } + + s.markCredentialUsedAsyncInternal(ctx, credential.ID) + logResult = "success" + logReason = "matched" + + return &federatedtypes.FederatedTokenResponse{ + AccessToken: tokenPair.AccessToken, + TokenType: "Bearer", + ExpiresIn: max(int(time.Until(tokenPair.ExpiresAt).Seconds()), 0), + IssuedTokenType: federatedtypes.IssuedTokenTypeAccessToken, + }, nil +} + +func (s *FederatedCredentialService) Create(ctx context.Context, callerUserID string, req federatedtypes.CreateFederatedCredential) (*federatedtypes.FederatedCredential, error) { + normalized, err := normalizeCreateFederatedCredentialInternal(req) + if err != nil { + return nil, err + } + if err := s.validateRoleGrantAgainstUserInternal(ctx, callerUserID, normalized.RoleID, normalized.EnvironmentID); err != nil { + return nil, err + } + + var created models.FederatedCredential + err = dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + serviceUser := models.User{ + Username: "svc_federated_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + DisplayName: pkgutils.StringPtrFromTrimmed("Federated: " + normalized.Name), + IsServiceAccount: true, + } + if err := tx.Create(&serviceUser).Error; err != nil { + return fmt.Errorf("failed to create federated service user: %w", err) + } + + created = models.FederatedCredential{ + Name: normalized.Name, + Description: normalized.Description, + Enabled: normalized.Enabled, + IssuerURL: normalized.IssuerURL, + Audiences: models.StringSlice(normalized.Audiences), + SubjectClaim: normalized.SubjectClaim, + SubjectMatch: normalized.SubjectMatch, + MatchType: normalized.MatchType, + RoleID: normalized.RoleID, + EnvironmentID: normalized.EnvironmentID, + IdentityUserID: serviceUser.ID, + TokenTTLSeconds: normalized.TokenTTLSeconds, + ExpiresAt: normalized.ExpiresAt, + } + if err := tx.Create(&created).Error; err != nil { + return fmt.Errorf("failed to create federated credential: %w", err) + } + + assignment := models.UserRoleAssignment{ + UserID: serviceUser.ID, + RoleID: normalized.RoleID, + EnvironmentID: normalized.EnvironmentID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to create federated role assignment: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + if s.roleService != nil { + s.roleService.InvalidateUser(created.IdentityUserID) + } + + reloaded, err := s.Get(ctx, created.ID) + if err != nil { + return nil, err + } + return reloaded, nil +} + +func (s *FederatedCredentialService) List(ctx context.Context, params pagination.QueryParams) ([]federatedtypes.FederatedCredential, pagination.Response, error) { + var credentials []models.FederatedCredential + query := s.db.WithContext(ctx). + Model(&models.FederatedCredential{}). + Preload("IdentityUser"). + Preload("Role"). + Preload("Environment") + + if term := strings.TrimSpace(params.Search); term != "" { + pattern := "%" + term + "%" + query = query.Where("name LIKE ? OR COALESCE(description, '') LIKE ? OR issuer_url LIKE ? OR subject_match LIKE ?", pattern, pattern, pattern, pattern) + } + + resp, err := pagination.PaginateAndSortDB(params, query, &credentials) + if err != nil { + return nil, pagination.Response{}, fmt.Errorf("failed to paginate federated credentials: %w", err) + } + + result := make([]federatedtypes.FederatedCredential, len(credentials)) + for i := range credentials { + result[i] = toFederatedCredentialDTOInternal(&credentials[i]) + } + return result, resp, nil +} + +func (s *FederatedCredentialService) Get(ctx context.Context, id string) (*federatedtypes.FederatedCredential, error) { + var credential models.FederatedCredential + if err := s.db.WithContext(ctx). + Preload("IdentityUser"). + Preload("Role"). + Preload("Environment"). + Where("id = ?", id). + First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, &common.FederatedCredentialNotFoundError{} + } + return nil, fmt.Errorf("failed to get federated credential: %w", err) + } + dto := toFederatedCredentialDTOInternal(&credential) + return &dto, nil +} + +func (s *FederatedCredentialService) Update(ctx context.Context, callerUserID string, id string, req federatedtypes.UpdateFederatedCredential) (*federatedtypes.FederatedCredential, error) { + var credential models.FederatedCredential + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, &common.FederatedCredentialNotFoundError{} + } + return nil, fmt.Errorf("failed to load federated credential: %w", err) + } + + updated, roleChanged, err := applyFederatedCredentialUpdateInternal(credential, req) + if err != nil { + return nil, err + } + revokeActiveSessions := credential.Enabled && !updated.Enabled + if roleChanged { + if err := s.validateRoleGrantAgainstUserInternal(ctx, callerUserID, updated.RoleID, updated.EnvironmentID); err != nil { + return nil, err + } + } + + err = dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Save(&updated).Error; err != nil { + return fmt.Errorf("failed to update federated credential: %w", err) + } + if revokeActiveSessions { + if err := revokeFederatedCredentialSessionsInternal(tx, updated.ID); err != nil { + return err + } + } + if roleChanged { + if err := tx.Where("user_id = ? AND source = ?", updated.IdentityUserID, models.RoleAssignmentSourceManual). + Delete(&models.UserRoleAssignment{}).Error; err != nil { + return fmt.Errorf("failed to clear federated role assignment: %w", err) + } + assignment := models.UserRoleAssignment{ + UserID: updated.IdentityUserID, + RoleID: updated.RoleID, + EnvironmentID: updated.EnvironmentID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to update federated role assignment: %w", err) + } + } + return nil + }) + if err != nil { + return nil, err + } + if roleChanged && s.roleService != nil { + s.roleService.InvalidateUser(updated.IdentityUserID) + } + + return s.Get(ctx, id) +} + +func (s *FederatedCredentialService) Delete(ctx context.Context, id string) error { + var credential models.FederatedCredential + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.FederatedCredentialNotFoundError{} + } + return fmt.Errorf("failed to load federated credential: %w", err) + } + + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Delete(&models.FederatedCredential{}, "id = ?", credential.ID).Error; err != nil { + return fmt.Errorf("failed to delete federated credential: %w", err) + } + if err := tx.Delete(&models.User{}, "id = ?", credential.IdentityUserID).Error; err != nil { + return fmt.Errorf("failed to delete federated service user: %w", err) + } + return nil + }) + if err != nil { + return err + } + if s.roleService != nil { + s.roleService.InvalidateUser(credential.IdentityUserID) + } + return nil +} + +func validateTokenExchangeRequestInternal(req federatedtypes.TokenExchangeRequest) error { + if req.GrantType != federatedtypes.TokenExchangeGrantType { + return &common.FederatedCredentialInvalidRequestError{} + } + if strings.TrimSpace(req.SubjectToken) == "" { + return &common.FederatedCredentialInvalidRequestError{} + } + switch req.SubjectTokenType { + case federatedtypes.SubjectTokenTypeJWT, federatedtypes.SubjectTokenTypeIDToken: + default: + return &common.FederatedCredentialInvalidRequestError{} + } + if req.RequestedTokenType != "" && req.RequestedTokenType != federatedtypes.RequestedTokenTypeAccessJWT { + return &common.FederatedCredentialInvalidRequestError{} + } + return nil +} + +func (s *FederatedCredentialService) listEnabledCredentialsForIssuerInternal(ctx context.Context, issuer string) ([]models.FederatedCredential, error) { + var credentials []models.FederatedCredential + if err := s.db.WithContext(ctx). + Where("issuer_url = ? AND enabled = ?", issuer, true). + Order("created_at ASC"). + Order("id ASC"). + Find(&credentials).Error; err != nil { + return nil, fmt.Errorf("failed to list federated credentials for issuer: %w", err) + } + + now := time.Now() + active := credentials[:0] + for _, credential := range credentials { + if credential.ExpiresAt != nil && now.After(*credential.ExpiresAt) { + continue + } + active = append(active, credential) + } + return active, nil +} + +func (s *FederatedCredentialService) verifySubjectTokenInternal(ctx context.Context, issuer string, rawToken string) (*oidc.IDToken, map[string]any, error) { + provider, err := s.providerForIssuerInternal(ctx, issuer) + if err != nil { + return nil, nil, err + } + + providerCtx := oidc.ClientContext(ctx, s.httpClient) + verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + idToken, err := verifier.Verify(providerCtx, rawToken) + if err != nil { + return nil, nil, err + } + + claims := map[string]any{} + if err := idToken.Claims(&claims); err != nil { + return nil, nil, err + } + return idToken, claims, nil +} + +func (s *FederatedCredentialService) recordTokenReplayGuardInternal(ctx context.Context, issuer string, rawToken string, claims map[string]any, expiresAt time.Time) error { + if expiresAt.IsZero() || time.Now().After(expiresAt) { + return &common.FederatedCredentialInvalidGrantError{} + } + + now := time.Now() + if err := s.db.WithContext(ctx). + Where("expires_at < ?", now). + Delete(&models.FederatedTokenReplay{}).Error; err != nil { + return fmt.Errorf("failed to prune federated token replay records: %w", err) + } + + replay := models.FederatedTokenReplay{ + TokenHash: federatedTokenReplayHashInternal(issuer, rawToken, claims), + IssuerURL: issuer, + ExpiresAt: expiresAt, + } + if err := s.db.WithContext(ctx).Create(&replay).Error; err != nil { + if isUniqueConstraintErrorInternal(err) { + return &common.FederatedCredentialInvalidGrantError{} + } + return fmt.Errorf("failed to record federated token replay guard: %w", err) + } + return nil +} + +func federatedTokenReplayHashInternal(issuer string, rawToken string, claims map[string]any) string { + tokenID := strings.TrimSpace(stringClaimByPathInternal(claims, "jti")) + tokenKind := "jti" + if tokenID == "" { + tokenID = rawToken + tokenKind = "token" + } + + sum := sha256.Sum256([]byte(issuer + "\x00" + tokenKind + "\x00" + tokenID)) + return hex.EncodeToString(sum[:]) +} + +func isUniqueConstraintErrorInternal(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate key") +} + +func (s *FederatedCredentialService) providerForIssuerInternal(ctx context.Context, issuer string) (*oidc.Provider, error) { + s.providerMu.RLock() + if provider := s.providers[issuer]; provider != nil { + s.providerMu.RUnlock() + return provider, nil + } + s.providerMu.RUnlock() + + v, err, _ := s.providerGroup.Do(issuer, func() (any, error) { + providerCtx := oidc.ClientContext(context.WithoutCancel(ctx), s.httpClient) + provider, err := oidc.NewProvider(providerCtx, issuer) + if err != nil { + return nil, fmt.Errorf("failed to discover federated issuer: %w", err) + } + + s.providerMu.Lock() + s.providers[issuer] = provider + s.providerMu.Unlock() + return provider, nil + }) + if err != nil { + return nil, err + } + + provider, ok := v.(*oidc.Provider) + if !ok || provider == nil { + return nil, fmt.Errorf("federated issuer discovery returned invalid provider") + } + return provider, nil +} + +func selectMatchingCredentialInternal(credentials []models.FederatedCredential, tokenAudiences []string, claims map[string]any) *models.FederatedCredential { + for i := range credentials { + credential := &credentials[i] + if !audienceMatchesInternal(tokenAudiences, []string(credential.Audiences)) { + continue + } + subjectClaim := strings.TrimSpace(credential.SubjectClaim) + if subjectClaim == "" { + subjectClaim = defaultFederatedSubjectClaim + } + subject := stringClaimByPathInternal(claims, subjectClaim) + if !subjectMatchesInternal(credential.MatchType, credential.SubjectMatch, subject) { + continue + } + return credential + } + return nil +} + +func audienceMatchesInternal(tokenAudiences, credentialAudiences []string) bool { + allowed := make(map[string]struct{}, len(credentialAudiences)) + for _, audience := range credentialAudiences { + audience = strings.TrimSpace(audience) + if audience != "" { + allowed[audience] = struct{}{} + } + } + for _, audience := range tokenAudiences { + if _, ok := allowed[audience]; ok { + return true + } + } + return false +} + +func subjectMatchesInternal(matchType, pattern, subject string) bool { + if subject == "" { + return false + } + switch normalizeMatchTypeInternal(matchType) { + case federatedtypes.MatchTypeGlob: + return anchoredGlobMatchesInternal(pattern, subject) + default: + return subject == pattern + } +} + +func anchoredGlobMatchesInternal(pattern, value string) bool { + var b strings.Builder + b.WriteString("^") + for _, r := range pattern { + switch r { + case '*': + b.WriteString(".*") + case '?': + b.WriteByte('.') + default: + b.WriteString(regexp.QuoteMeta(string(r))) + } + } + b.WriteString("$") + matched, err := regexp.MatchString(b.String(), value) + return err == nil && matched +} + +func unverifiedTokenExchangeMetadataInternal(rawToken string) (string, string, []string) { + claims := jwtclaims.ParseJWTClaims(rawToken) + if claims == nil { + return "", "", nil + } + return stringClaimByPathInternal(claims, "iss"), stringClaimByPathInternal(claims, "sub"), stringSliceClaimInternal(claims, "aud") +} + +func stringClaimByPathInternal(claims map[string]any, path string) string { + value, ok := jwtclaims.GetByPath(claims, path) + if !ok { + return "" + } + return pkgutils.ToString(value) +} + +func stringSliceClaimInternal(claims map[string]any, path string) []string { + value, ok := jwtclaims.GetByPath(claims, path) + if !ok || value == nil { + return nil + } + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) == "" { + return nil + } + return []string{strings.TrimSpace(typed)} + case []string: + return pkgutils.UniqueNonEmptyStrings(typed) + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if value := pkgutils.ToString(item); value != "" { + out = append(out, value) + } + } + return pkgutils.UniqueNonEmptyStrings(out) + default: + return nil + } +} + +func normalizeCreateFederatedCredentialInternal(req federatedtypes.CreateFederatedCredential) (federatedtypes.CreateFederatedCredential, error) { + req.Name = strings.TrimSpace(req.Name) + req.IssuerURL = strings.TrimRight(strings.TrimSpace(req.IssuerURL), "/") + req.SubjectClaim = strings.TrimSpace(req.SubjectClaim) + req.SubjectMatch = strings.TrimSpace(req.SubjectMatch) + req.MatchType = normalizeMatchTypeInternal(req.MatchType) + req.Audiences = pkgutils.UniqueNonEmptyStrings(req.Audiences) + req.EnvironmentID = pkgutils.StringPtrFromTrimmed(pkgutils.DerefString(req.EnvironmentID)) + req.TokenTTLSeconds = clampFederatedTokenTTLSecondsInternal(req.TokenTTLSeconds) + + if req.SubjectClaim == "" { + req.SubjectClaim = defaultFederatedSubjectClaim + } + if req.Name == "" || req.SubjectMatch == "" || req.RoleID == "" || len(req.Audiences) == 0 { + return req, &common.FederatedCredentialInvalidError{} + } + if err := validateIssuerURLInternal(req.IssuerURL); err != nil { + return req, err + } + if err := validateSubjectMatchInternal(req.MatchType, req.SubjectMatch); err != nil { + return req, err + } + return req, nil +} + +func applyFederatedCredentialUpdateInternal(existing models.FederatedCredential, req federatedtypes.UpdateFederatedCredential) (models.FederatedCredential, bool, error) { + roleChanged := false + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return existing, false, &common.FederatedCredentialInvalidError{} + } + existing.Name = name + } + if req.Description != nil { + existing.Description = req.Description + } + if req.Enabled != nil { + existing.Enabled = *req.Enabled + } + if req.IssuerURL != nil { + issuerURL := strings.TrimRight(strings.TrimSpace(*req.IssuerURL), "/") + if err := validateIssuerURLInternal(issuerURL); err != nil { + return existing, false, err + } + existing.IssuerURL = issuerURL + } + if req.Audiences != nil { + audiences := pkgutils.UniqueNonEmptyStrings(req.Audiences) + if len(audiences) == 0 { + return existing, false, &common.FederatedCredentialInvalidError{} + } + existing.Audiences = models.StringSlice(audiences) + } + if req.SubjectClaim != nil { + subjectClaim := strings.TrimSpace(*req.SubjectClaim) + if subjectClaim == "" { + subjectClaim = defaultFederatedSubjectClaim + } + existing.SubjectClaim = subjectClaim + } + if req.SubjectMatch != nil { + subjectMatch := strings.TrimSpace(*req.SubjectMatch) + if subjectMatch == "" { + return existing, false, &common.FederatedCredentialInvalidError{} + } + existing.SubjectMatch = subjectMatch + } + if req.MatchType != nil { + existing.MatchType = normalizeMatchTypeInternal(*req.MatchType) + } + if err := validateSubjectMatchInternal(existing.MatchType, existing.SubjectMatch); err != nil { + return existing, false, err + } + roleChanged, err := applyFederatedRoleScopeUpdateInternal(&existing, req.RoleID, req.EnvironmentID) + if err != nil { + return existing, false, err + } + if req.TokenTTLSeconds != nil { + existing.TokenTTLSeconds = clampFederatedTokenTTLSecondsInternal(*req.TokenTTLSeconds) + } + if req.ExpiresAt != nil { + existing.ExpiresAt = req.ExpiresAt + } + return existing, roleChanged, nil +} + +func applyFederatedRoleScopeUpdateInternal(existing *models.FederatedCredential, roleID *string, environmentID *string) (bool, error) { + if existing == nil { + return false, &common.FederatedCredentialInvalidError{} + } + + roleChanged := false + if roleID != nil { + normalizedRoleID := strings.TrimSpace(*roleID) + if normalizedRoleID == "" { + return false, &common.FederatedCredentialInvalidError{} + } + roleChanged = roleChanged || normalizedRoleID != existing.RoleID + existing.RoleID = normalizedRoleID + } + if environmentID != nil { + normalized := pkgutils.StringPtrFromTrimmed(*environmentID) + roleChanged = roleChanged || pkgutils.DerefString(existing.EnvironmentID) != pkgutils.DerefString(normalized) + existing.EnvironmentID = normalized + } + return roleChanged, nil +} + +func normalizeMatchTypeInternal(matchType string) string { + switch strings.ToLower(strings.TrimSpace(matchType)) { + case federatedtypes.MatchTypeGlob: + return federatedtypes.MatchTypeGlob + default: + return federatedtypes.MatchTypeExact + } +} + +func validateIssuerURLInternal(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed == nil || parsed.Host == "" || parsed.Scheme != "https" { + return fmt.Errorf("%w: issuerUrl must be an HTTPS URL", &common.FederatedCredentialInvalidError{}) + } + return nil +} + +func validateSubjectMatchInternal(matchType, subjectMatch string) error { + if strings.TrimSpace(subjectMatch) == "" { + return &common.FederatedCredentialInvalidError{} + } + if normalizeMatchTypeInternal(matchType) == federatedtypes.MatchTypeGlob && strings.TrimSpace(subjectMatch) == "*" { + return &common.FederatedCredentialInvalidError{} + } + return nil +} + +func (s *FederatedCredentialService) validateRoleGrantAgainstUserInternal(ctx context.Context, userID, roleID string, environmentID *string) error { + if s.roleService == nil || strings.TrimSpace(userID) == "" { + return nil + } + + user, err := s.userService.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("load user for federated role validation: %w", err) + } + + ps, err := s.roleService.ResolvePermissions(ctx, user) + if err != nil { + return fmt.Errorf("resolve user permissions: %w", err) + } + + if err := s.roleService.ValidateRoleAssignmentAgainstCaller(ctx, ps, roleID, environmentID); err != nil { + if common.IsRolePermissionEscalationError(err) { + return fmt.Errorf("%w: %w", &common.FederatedCredentialPermissionEscalationError{}, err) + } + return fmt.Errorf("%w: %w", &common.FederatedCredentialInvalidError{}, err) + } + return nil +} + +func revokeFederatedCredentialSessionsInternal(tx *gorm.DB, credentialID string) error { + if tx == nil || strings.TrimSpace(credentialID) == "" { + return nil + } + + now := time.Now() + if err := tx.Model(&models.UserSession{}). + Where("federated_credential_id = ? AND revoked_at IS NULL", credentialID). + Updates(map[string]any{"revoked_at": now, "updated_at": now}).Error; err != nil { + return fmt.Errorf("failed to revoke federated credential sessions: %w", err) + } + return nil +} + +func toFederatedCredentialDTOInternal(credential *models.FederatedCredential) federatedtypes.FederatedCredential { + if credential == nil { + return federatedtypes.FederatedCredential{} + } + dto := federatedtypes.FederatedCredential{ + ID: credential.ID, + Name: credential.Name, + Description: credential.Description, + Enabled: credential.Enabled, + IssuerURL: credential.IssuerURL, + Audiences: []string(credential.Audiences), + SubjectClaim: credential.SubjectClaim, + SubjectMatch: credential.SubjectMatch, + MatchType: credential.MatchType, + RoleID: credential.RoleID, + EnvironmentID: credential.EnvironmentID, + IdentityUserID: credential.IdentityUserID, + TokenTTLSeconds: credential.TokenTTLSeconds, + LastUsedAt: credential.LastUsedAt, + ExpiresAt: credential.ExpiresAt, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } + if credential.IdentityUser != nil { + dto.ServiceUsername = credential.IdentityUser.Username + } + if credential.Role != nil { + dto.RoleName = credential.Role.Name + } + if credential.Environment != nil { + dto.EnvironmentName = credential.Environment.Name + } + return dto +} + +func (s *FederatedCredentialService) markCredentialUsedAsyncInternal(ctx context.Context, credentialID string) { + go func() { + bgCtx := context.WithoutCancel(ctx) + now := time.Now() + cutoff := now.Add(-federatedCredentialLastUsedWriteWindow) + if err := s.db.WithContext(bgCtx). + Model(&models.FederatedCredential{}). + Where("id = ? AND (last_used_at IS NULL OR last_used_at < ?)", credentialID, cutoff). + Update("last_used_at", now).Error; err != nil { + slog.WarnContext(bgCtx, "failed to update federated credential last_used_at", "credential_id", credentialID, "error", err) + } + }() +} + +func (s *FederatedCredentialService) logExchangeInternal(ctx context.Context, result, reason, issuer, subject string, audiences []string, credential *models.FederatedCredential, user *models.User) { + credentialID := "" + credentialName := "" + if credential != nil { + credentialID = credential.ID + credentialName = credential.Name + } + slog.InfoContext(ctx, "Federated credential token exchange", + "result", result, + "reason", reason, + "issuer", issuer, + "subject", subject, + "audiences", audiences, + "credential_id", credentialID, + ) + + if s.eventService == nil { + return + } + + metadata := models.JSON{ + "action": "federated_token_exchange", + "result": result, + "reason": reason, + "issuer": issuer, + "subject": subject, + "audiences": audiences, + "credentialId": credentialID, + } + + userID := "" + username := "" + if user != nil { + userID = user.ID + username = user.Username + } + severity := models.EventSeverityInfo + title := "Federated credential token exchange" + if result != "success" { + severity = models.EventSeverityWarning + title = "Federated credential token exchange rejected" + } + + go func() { + bgCtx := context.WithoutCancel(ctx) + _, err := s.eventService.CreateEvent(bgCtx, CreateEventRequest{ + Type: models.EventTypeFederatedExchange, + Severity: severity, + Title: title, + Description: "Workload identity federation token exchange", + ResourceType: pkgutils.StringPtrFromTrimmed("federated_credential"), + ResourceID: pkgutils.StringPtrFromTrimmed(credentialID), + ResourceName: pkgutils.StringPtrFromTrimmed(credentialName), + UserID: pkgutils.StringPtrFromTrimmed(userID), + Username: pkgutils.StringPtrFromTrimmed(username), + Metadata: metadata, + }) + if err != nil { + slog.WarnContext(bgCtx, "failed to audit federated credential token exchange", "error", err) + } + }() +} diff --git a/backend/internal/services/federated_credential_service_test.go b/backend/internal/services/federated_credential_service_test.go new file mode 100644 index 0000000000..745d982bdf --- /dev/null +++ b/backend/internal/services/federated_credential_service_test.go @@ -0,0 +1,363 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" +) + +type federatedTestIssuerInternal struct { + IssuerURL string + private *rsa.PrivateKey + keyID string + server *httptest.Server +} + +func newFederatedTestIssuerInternal(t *testing.T) *federatedTestIssuerInternal { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + issuer := &federatedTestIssuerInternal{ + private: privateKey, + keyID: "federated-test-key", + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer.IssuerURL, + "jwks_uri": issuer.IssuerURL + "/jwks", + "authorization_endpoint": issuer.IssuerURL + "/authorize", + "token_endpoint": issuer.IssuerURL + "/token", + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + })) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + pub := privateKey.PublicKey + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "use": "sig", + "kid": issuer.keyID, + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }, + }, + })) + }) + + issuer.server = httptest.NewServer(mux) + issuer.IssuerURL = issuer.server.URL + t.Cleanup(issuer.server.Close) + + return issuer +} + +func (i *federatedTestIssuerInternal) tokenInternal(t *testing.T, subject string, audience []string) string { + t.Helper() + + now := time.Now() + claims := jwt.MapClaims{ + "iss": i.IssuerURL, + "sub": subject, + "aud": audience, + "iat": now.Unix(), + "nbf": now.Add(-time.Minute).Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = i.keyID + signed, err := token.SignedString(i.private) + require.NoError(t, err) + return signed +} + +func setupFederatedCredentialServiceTestDBInternal(t *testing.T) *database.DB { + t.Helper() + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())) + db, err := gorm.Open(glsqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.SettingVariable{}, + &models.User{}, + &models.UserSession{}, + &models.Role{}, + &models.UserRoleAssignment{}, + &models.FederatedCredential{}, + &models.FederatedTokenReplay{}, + &models.Event{}, + )) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + return &database.DB{DB: db} +} + +func setupFederatedCredentialServiceInternal(t *testing.T, issuer *federatedTestIssuerInternal) (*FederatedCredentialService, *AuthService, *database.DB) { + t.Helper() + + ctx := context.Background() + db := setupFederatedCredentialServiceTestDBInternal(t) + roleSvc := NewRoleService(db) + userSvc := NewUserService(db).WithRoleService(roleSvc) + sessionSvc := NewSessionService(db) + settingsSvc, err := NewSettingsService(ctx, db) + require.NoError(t, err) + eventSvc := NewEventService(db, &config.Config{}, nil) + authSvc := NewAuthService(userSvc, settingsSvc, eventSvc, sessionSvc, roleSvc, "test-federated-secret", &config.Config{ + JWTRefreshExpiry: 24 * time.Hour, + }) + + service := NewFederatedCredentialService(db, authSvc, userSvc, settingsSvc, eventSvc, issuer.server.Client()).WithRoleService(roleSvc) + + role := models.Role{ + BaseModel: models.BaseModel{ID: "role-federated-viewer"}, + Name: "Federated Viewer", + Permissions: models.StringSlice{authz.PermProjectsList}, + } + require.NoError(t, db.WithContext(ctx).Create(&role).Error) + + serviceUser := models.User{ + BaseModel: models.BaseModel{ID: "user-federated-service"}, + Username: "svc-federated-demo", + IsServiceAccount: true, + } + require.NoError(t, db.WithContext(ctx).Create(&serviceUser).Error) + require.NoError(t, db.WithContext(ctx).Create(&models.UserRoleAssignment{ + UserID: serviceUser.ID, + RoleID: role.ID, + }).Error) + + credential := models.FederatedCredential{ + BaseModel: models.BaseModel{ID: "cred-github-actions"}, + Name: "GitHub Actions", + Enabled: true, + IssuerURL: issuer.IssuerURL, + Audiences: models.StringSlice{"arcane-ci"}, + SubjectClaim: "sub", + SubjectMatch: "repo:getarcaneapp/arcane:*", + MatchType: federatedtypes.MatchTypeGlob, + RoleID: role.ID, + IdentityUserID: serviceUser.ID, + TokenTTLSeconds: 900, + } + require.NoError(t, db.WithContext(ctx).Create(&credential).Error) + + return service, authSvc, db +} + +func TestFederatedCredentialServiceExchangeToken(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, authSvc, db := setupFederatedCredentialServiceInternal(t, issuer) + ctx := context.Background() + + tests := []struct { + name string + token string + wantError func(error) bool + }{ + { + name: "issues an Arcane bearer token for a matching issuer audience and subject", + token: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + }, + { + name: "rejects audience mismatch", + token: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"other-audience"}), + wantError: func(err error) bool { + return common.IsErrorFederatedCredentialInvalidGrant(err) + }, + }, + { + name: "rejects subject mismatch", + token: issuer.tokenInternal(t, "repo:other/repo:ref:refs/heads/main", []string{"arcane-ci"}), + wantError: func(err error) bool { + return common.IsErrorFederatedCredentialInvalidGrant(err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.ExchangeToken(ctx, federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: tt.token, + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + if tt.wantError != nil { + require.Error(t, err) + require.True(t, tt.wantError(err), "unexpected error: %v", err) + require.Nil(t, resp) + return + } + + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "Bearer", resp.TokenType) + require.Equal(t, federatedtypes.IssuedTokenTypeAccessToken, resp.IssuedTokenType) + require.Positive(t, resp.ExpiresIn) + require.NotEmpty(t, resp.AccessToken) + + user, sessionID, err := authSvc.VerifyToken(ctx, resp.AccessToken) + require.NoError(t, err) + require.Equal(t, "user-federated-service", user.ID) + + var session models.UserSession + require.NoError(t, db.WithContext(ctx).Where("id = ?", sessionID).First(&session).Error) + require.Equal(t, models.UserSessionSourceFederated, session.Source) + require.NotNil(t, session.FederatedCredentialID) + require.Equal(t, "cred-github-actions", *session.FederatedCredentialID) + }) + } +} + +func TestFederatedCredentialServiceExchangeTokenRejectsIssuerWithoutCredentialInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + otherIssuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + + resp, err := service.ExchangeToken(context.Background(), federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: otherIssuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalidGrant(err), "unexpected error: %v", err) + require.Nil(t, resp) +} + +func TestFederatedCredentialServiceExchangeTokenDoesNotRequireGlobalFeatureFlagInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + service.settingsService = nil + + resp, err := service.ExchangeToken(context.Background(), federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.AccessToken) +} + +func TestFederatedCredentialServiceExchangeTokenRejectsExpiredCredentialInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, db := setupFederatedCredentialServiceInternal(t, issuer) + expiredAt := time.Now().Add(-time.Minute) + require.NoError(t, db.WithContext(context.Background()). + Model(&models.FederatedCredential{}). + Where("id = ?", "cred-github-actions"). + Update("expires_at", expiredAt).Error) + + resp, err := service.ExchangeToken(context.Background(), federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalidGrant(err), "unexpected error: %v", err) + require.Nil(t, resp) +} + +func TestFederatedCredentialServiceUpdateDisableRevokesIssuedSessionsInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, authSvc, _ := setupFederatedCredentialServiceInternal(t, issuer) + ctx := context.Background() + + resp, err := service.ExchangeToken(ctx, federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + require.NoError(t, err) + require.NotNil(t, resp) + + disabled := false + _, err = service.Update(ctx, "admin-user", "cred-github-actions", federatedtypes.UpdateFederatedCredential{ + Enabled: &disabled, + }) + require.NoError(t, err) + + _, _, err = authSvc.VerifyToken(ctx, resp.AccessToken) + require.Error(t, err) + require.True(t, common.IsSessionRevokedError(err), "unexpected error: %v", err) +} + +func TestFederatedCredentialServiceRejectsReplayedSubjectTokenInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + ctx := context.Background() + subjectToken := issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}) + req := federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: subjectToken, + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + } + + first, err := service.ExchangeToken(ctx, req) + require.NoError(t, err) + require.NotNil(t, first) + + second, err := service.ExchangeToken(ctx, req) + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalidGrant(err), "unexpected error: %v", err) + require.Nil(t, second) +} + +func TestFederatedCredentialServiceCreateRejectsBareWildcardGlob(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + + _, err := service.Create(context.Background(), "admin-user", federatedtypes.CreateFederatedCredential{ + Name: "Unsafe wildcard", + IssuerURL: "https://token.actions.githubusercontent.com", + Audiences: []string{"arcane-ci"}, + SubjectMatch: "*", + MatchType: federatedtypes.MatchTypeGlob, + RoleID: "role-federated-viewer", + TokenTTLSeconds: 900, + }) + + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalid(err), "unexpected error: %v", err) +} diff --git a/backend/internal/services/playwright_service.go b/backend/internal/services/playwright_service.go index d3ea9553d2..6552a6c1fa 100644 --- a/backend/internal/services/playwright_service.go +++ b/backend/internal/services/playwright_service.go @@ -6,21 +6,27 @@ import ( "context" "fmt" "log/slog" + "strings" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/apikey" + "github.com/google/uuid" + "gorm.io/gorm" ) type PlaywrightService struct { - apiKeyService *ApiKeyService - userService *UserService + apiKeyService *ApiKeyService + userService *UserService + federatedCredentialService *FederatedCredentialService } -func NewPlaywrightService(apiKeyService *ApiKeyService, userService *UserService) *PlaywrightService { +func NewPlaywrightService(apiKeyService *ApiKeyService, userService *UserService, federatedCredentialService *FederatedCredentialService) *PlaywrightService { return &PlaywrightService{ - apiKeyService: apiKeyService, - userService: userService, + apiKeyService: apiKeyService, + userService: userService, + federatedCredentialService: federatedCredentialService, } } @@ -92,3 +98,58 @@ func (ps *PlaywrightService) DeleteAllTestApiKeys(ctx context.Context) error { slog.Info("Playwright: Test API keys deleted", "count", len(apiKeys)) return nil } + +func (ps *PlaywrightService) CreateTestFederatedCredential(ctx context.Context, issuerURL string, audiences []string, subject string, roleID string, tokenTTLSeconds int) (string, error) { + if ps.federatedCredentialService == nil || ps.federatedCredentialService.db == nil { + return "", fmt.Errorf("federated credential service is not available") + } + if strings.TrimSpace(issuerURL) == "" || strings.TrimSpace(subject) == "" || strings.TrimSpace(roleID) == "" || len(audiences) == 0 { + return "", fmt.Errorf("issuerUrl, subject, roleId, and audiences are required") + } + if tokenTTLSeconds <= 0 { + tokenTTLSeconds = 600 + } + + var credentialID string + err := ps.federatedCredentialService.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + serviceUser := models.User{ + Username: "svc_federated_e2e_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + IsServiceAccount: true, + } + if err := tx.Create(&serviceUser).Error; err != nil { + return fmt.Errorf("failed to create federated e2e user: %w", err) + } + + credential := models.FederatedCredential{ + Name: "Playwright Federated Credential", + Enabled: true, + IssuerURL: strings.TrimRight(strings.TrimSpace(issuerURL), "/"), + Audiences: models.StringSlice(audiences), + SubjectClaim: "sub", + SubjectMatch: strings.TrimSpace(subject), + MatchType: models.FederatedCredentialMatchExact, + RoleID: strings.TrimSpace(roleID), + IdentityUserID: serviceUser.ID, + TokenTTLSeconds: tokenTTLSeconds, + } + if err := tx.Create(&credential).Error; err != nil { + return fmt.Errorf("failed to create federated e2e credential: %w", err) + } + + assignment := models.UserRoleAssignment{ + UserID: serviceUser.ID, + RoleID: credential.RoleID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to create federated e2e role assignment: %w", err) + } + + credentialID = credential.ID + return nil + }) + if err != nil { + return "", err + } + return credentialID, nil +} diff --git a/backend/internal/services/role_service.go b/backend/internal/services/role_service.go index 6ecbc3c169..8b9869da90 100644 --- a/backend/internal/services/role_service.go +++ b/backend/internal/services/role_service.go @@ -17,6 +17,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cache" "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" roletypes "github.com/getarcaneapp/arcane/types/role" @@ -569,9 +570,10 @@ func (s *RoleService) ReplaceOidcAssignments(ctx context.Context, userID string, func (s *RoleService) CountGlobalAdminsExcludingUser(ctx context.Context, excludedUserID string) (int, error) { var count int64 if err := s.db.WithContext(ctx). - Model(&models.UserRoleAssignment{}). - Distinct("user_id"). - Where("role_id = ? AND environment_id IS NULL AND user_id <> ?", authz.BuiltInRoleAdmin, excludedUserID). + Table("user_role_assignments AS ura"). + Joins("JOIN users ON users.id = ura.user_id"). + Distinct("ura.user_id"). + Where("ura.role_id = ? AND ura.environment_id IS NULL AND ura.user_id <> ? AND users.is_service_account = ?", authz.BuiltInRoleAdmin, excludedUserID, false). Count(&count).Error; err != nil { return 0, fmt.Errorf("failed to count global admins: %w", err) } @@ -580,9 +582,10 @@ func (s *RoleService) CountGlobalAdminsExcludingUser(ctx context.Context, exclud func (s *RoleService) countGlobalAdminsInternal(_ context.Context, tx *gorm.DB) (int, error) { var count int64 - if err := tx.Model(&models.UserRoleAssignment{}). - Distinct("user_id"). - Where("role_id = ? AND environment_id IS NULL", authz.BuiltInRoleAdmin). + if err := tx.Table("user_role_assignments AS ura"). + Joins("JOIN users ON users.id = ura.user_id"). + Distinct("ura.user_id"). + Where("ura.role_id = ? AND ura.environment_id IS NULL AND users.is_service_account = ?", authz.BuiltInRoleAdmin, false). Count(&count).Error; err != nil { return 0, fmt.Errorf("failed to count global admins: %w", err) } @@ -913,6 +916,26 @@ func (s *RoleService) ValidatePermissionsAgainstCaller(caller *authz.PermissionS if err := validatePermissionsInternal(desired); err != nil { return err } + return validatePermissionSetAgainstCallerInternal(caller, desired, "") +} + +// ValidateRoleAssignmentAgainstCaller rejects assigning a role at the requested +// scope when the caller does not hold every permission in that role at that +// same scope. +func (s *RoleService) ValidateRoleAssignmentAgainstCaller(ctx context.Context, caller *authz.PermissionSet, roleID string, environmentID *string) error { + role, err := s.GetRole(ctx, roleID) + if err != nil { + return err + } + + desired := []string(role.Permissions) + if err := validatePermissionsInternal(desired); err != nil { + return err + } + return validatePermissionSetAgainstCallerInternal(caller, desired, pkgutils.DerefString(environmentID)) +} + +func validatePermissionSetAgainstCallerInternal(caller *authz.PermissionSet, desired []string, environmentID string) error { if caller == nil { if len(desired) == 0 { return nil @@ -923,7 +946,7 @@ func (s *RoleService) ValidatePermissionsAgainstCaller(caller *authz.PermissionS return nil } for _, p := range desired { - if !caller.Allows(p, "") { + if !caller.Allows(p, environmentID) { return &common.RolePermissionEscalationError{Perm: p} } } diff --git a/backend/internal/services/session_service.go b/backend/internal/services/session_service.go index 88ded2334b..ef3d7d39b7 100644 --- a/backend/internal/services/session_service.go +++ b/backend/internal/services/session_service.go @@ -37,6 +37,7 @@ func (s *SessionService) CreateSession(ctx context.Context, userID string, expir RefreshTokenHash: refreshHash, UserAgent: pkgutils.StringPtrFromTrimmed(meta.UserAgent), IPAddress: pkgutils.StringPtrFromTrimmed(meta.IPAddress), + Source: models.UserSessionSourceLocal, LastUsedAt: now, ExpiresAt: expiresAt, } @@ -48,6 +49,26 @@ func (s *SessionService) CreateSession(ctx context.Context, userID string, expir return session, refreshJTI, nil } +func (s *SessionService) CreateFederatedSession(ctx context.Context, userID string, expiresAt time.Time, credentialID string) (*models.UserSession, error) { + refreshHash := hashRefreshJTIInternal(uuid.NewString()) + now := time.Now() + + session := &models.UserSession{ + UserID: userID, + RefreshTokenHash: refreshHash, + Source: models.UserSessionSourceFederated, + FederatedCredentialID: pkgutils.StringPtrFromTrimmed(credentialID), + LastUsedAt: now, + ExpiresAt: expiresAt, + } + + if err := s.db.WithContext(ctx).Create(session).Error; err != nil { + return nil, fmt.Errorf("failed to create federated user session: %w", err) + } + + return session, nil +} + func (s *SessionService) GetSessionByID(ctx context.Context, sessionID string) (*models.UserSession, error) { if strings.TrimSpace(sessionID) == "" { return nil, ErrInvalidToken diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go index 93149354e4..b18601d562 100644 --- a/backend/internal/services/user_service.go +++ b/backend/internal/services/user_service.go @@ -403,7 +403,9 @@ func (s *UserService) UpgradePasswordHash(ctx context.Context, userID, password func (s *UserService) ListUsersPaginated(ctx context.Context, params pagination.QueryParams) ([]user.User, pagination.Response, error) { var users []models.User - query := s.db.WithContext(ctx).Model(&models.User{}) + query := s.db.WithContext(ctx). + Model(&models.User{}). + Where("is_service_account = ?", false) if term := strings.TrimSpace(params.Search); term != "" { searchPattern := "%" + term + "%" diff --git a/backend/pkg/authz/catalog.go b/backend/pkg/authz/catalog.go index 07939cce13..fd75a75f57 100644 --- a/backend/pkg/authz/catalog.go +++ b/backend/pkg/authz/catalog.go @@ -42,6 +42,13 @@ var permissionCatalog = []PermissionCatalogResource{ {"update", PermApiKeysUpdate, "Update", ""}, {"delete", PermApiKeysDelete, "Delete", ""}, }}, + {"federated", "Federated Credentials", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermFederatedList, "List", ""}, + {"read", PermFederatedRead, "Read", ""}, + {"create", PermFederatedCreate, "Create", ""}, + {"update", PermFederatedUpdate, "Update", ""}, + {"delete", PermFederatedDelete, "Delete", ""}, + }}, {"settings", "Settings", PermissionScopeGlobal, []PermissionCatalogAction{ {"read", PermSettingsRead, "Read", ""}, {"write", PermSettingsWrite, "Write", ""}, diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go index 30285539f3..94db9a094d 100644 --- a/backend/pkg/authz/permissions.go +++ b/backend/pkg/authz/permissions.go @@ -38,6 +38,12 @@ const ( PermApiKeysUpdate = "apikeys:update" PermApiKeysDelete = "apikeys:delete" + PermFederatedList = "federated:list" + PermFederatedRead = "federated:read" + PermFederatedCreate = "federated:create" + PermFederatedUpdate = "federated:update" + PermFederatedDelete = "federated:delete" + PermSettingsRead = "settings:read" PermSettingsWrite = "settings:write" @@ -257,6 +263,7 @@ func BuiltInEditorPermissions() []string { PermUsersList, PermUsersRead, PermRolesList, PermRolesRead, PermApiKeysList, PermApiKeysRead, + PermFederatedList, PermFederatedRead, PermSettingsRead, PermEnvironmentsList, PermEnvironmentsRead, PermEnvironmentsSync, PermRegistriesList, PermRegistriesRead, @@ -312,6 +319,7 @@ func BuiltInViewerPermissions() []string { PermUsersList, PermUsersRead, PermRolesList, PermRolesRead, PermApiKeysList, PermApiKeysRead, + PermFederatedList, PermFederatedRead, PermSettingsRead, PermEnvironmentsList, PermEnvironmentsRead, PermRegistriesList, PermRegistriesRead, diff --git a/backend/resources/migrations/postgres/056_add_federated_credentials.down.sql b/backend/resources/migrations/postgres/056_add_federated_credentials.down.sql new file mode 100644 index 0000000000..b52076a30a --- /dev/null +++ b/backend/resources/migrations/postgres/056_add_federated_credentials.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS idx_user_sessions_federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN IF EXISTS federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN IF EXISTS source; +ALTER TABLE users DROP COLUMN IF EXISTS is_service_account; + +DROP INDEX IF EXISTS idx_federated_credentials_role_id; +DROP INDEX IF EXISTS idx_federated_credentials_identity_user_id; +DROP INDEX IF EXISTS idx_federated_credentials_enabled; +DROP INDEX IF EXISTS idx_federated_credentials_issuer_url; +DROP INDEX IF EXISTS idx_federated_token_replays_expires_at; +DROP INDEX IF EXISTS idx_federated_token_replays_issuer_url; +DROP TABLE IF EXISTS federated_token_replays; +DROP TABLE IF EXISTS federated_credentials; diff --git a/backend/resources/migrations/postgres/056_add_federated_credentials.up.sql b/backend/resources/migrations/postgres/056_add_federated_credentials.up.sql new file mode 100644 index 0000000000..23b7e33182 --- /dev/null +++ b/backend/resources/migrations/postgres/056_add_federated_credentials.up.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS federated_credentials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + issuer_url TEXT NOT NULL, + audiences TEXT NOT NULL, + subject_claim TEXT NOT NULL DEFAULT 'sub', + subject_match TEXT NOT NULL, + match_type TEXT NOT NULL DEFAULT 'exact', + role_id TEXT NOT NULL REFERENCES roles(id), + environment_id TEXT REFERENCES environments(id) ON DELETE SET NULL, + identity_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_ttl_seconds INTEGER NOT NULL DEFAULT 900, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_federated_credentials_issuer_url ON federated_credentials(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_enabled ON federated_credentials(enabled); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_identity_user_id ON federated_credentials(identity_user_id); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_role_id ON federated_credentials(role_id); + +CREATE TABLE IF NOT EXISTS federated_token_replays ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + issuer_url TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_issuer_url ON federated_token_replays(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_expires_at ON federated_token_replays(expires_at); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS source TEXT; +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS federated_credential_id TEXT REFERENCES federated_credentials(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_user_sessions_federated_credential_id ON user_sessions(federated_credential_id); diff --git a/backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql b/backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql new file mode 100644 index 0000000000..2173d5356c --- /dev/null +++ b/backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS idx_user_sessions_federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN source; +ALTER TABLE users DROP COLUMN is_service_account; + +DROP INDEX IF EXISTS idx_federated_credentials_role_id; +DROP INDEX IF EXISTS idx_federated_credentials_identity_user_id; +DROP INDEX IF EXISTS idx_federated_credentials_enabled; +DROP INDEX IF EXISTS idx_federated_credentials_issuer_url; +DROP INDEX IF EXISTS idx_federated_token_replays_expires_at; +DROP INDEX IF EXISTS idx_federated_token_replays_issuer_url; +DROP TABLE IF EXISTS federated_token_replays; +DROP TABLE IF EXISTS federated_credentials; diff --git a/backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql b/backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql new file mode 100644 index 0000000000..9c171e97d2 --- /dev/null +++ b/backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS federated_credentials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT 0, + issuer_url TEXT NOT NULL, + audiences TEXT NOT NULL, + subject_claim TEXT NOT NULL DEFAULT 'sub', + subject_match TEXT NOT NULL, + match_type TEXT NOT NULL DEFAULT 'exact', + role_id TEXT NOT NULL, + environment_id TEXT, + identity_user_id TEXT NOT NULL, + token_ttl_seconds INTEGER NOT NULL DEFAULT 900, + last_used_at DATETIME, + expires_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (role_id) REFERENCES roles(id), + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE SET NULL, + FOREIGN KEY (identity_user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_federated_credentials_issuer_url ON federated_credentials(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_enabled ON federated_credentials(enabled); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_identity_user_id ON federated_credentials(identity_user_id); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_role_id ON federated_credentials(role_id); + +CREATE TABLE IF NOT EXISTS federated_token_replays ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + issuer_url TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_issuer_url ON federated_token_replays(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_expires_at ON federated_token_replays(expires_at); + +ALTER TABLE users ADD COLUMN is_service_account BOOLEAN NOT NULL DEFAULT 0; +ALTER TABLE user_sessions ADD COLUMN source TEXT; +ALTER TABLE user_sessions ADD COLUMN federated_credential_id TEXT REFERENCES federated_credentials(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_user_sessions_federated_credential_id ON user_sessions(federated_credential_id); diff --git a/cli/internal/ci/ci_detect.go b/cli/internal/ci/ci_detect.go new file mode 100644 index 0000000000..cf922b0277 --- /dev/null +++ b/cli/internal/ci/ci_detect.go @@ -0,0 +1,116 @@ +package ci + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + ProviderAuto = "auto" + ProviderGitHub = "github" + ProviderGitLab = "gitlab" + ProviderGeneric = "generic" +) + +// DetectToken resolves a CI-issued OIDC token from the requested provider. +func DetectToken(ctx context.Context, provider string, audience string, getenv func(string) string, httpClient *http.Client) (string, string, error) { + provider = normalizeFederatedProviderInternal(provider) + if getenv == nil { + getenv = func(string) string { return "" } + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + switch provider { + case ProviderAuto: + if token, err := mintGitHubActionsTokenInternal(ctx, audience, getenv, httpClient); err == nil { + return token, ProviderGitHub, nil + } + if token := strings.TrimSpace(getenv("CI_JOB_JWT_V2")); token != "" { + return token, ProviderGitLab, nil + } + return "", "", fmt.Errorf("no supported CI OIDC token source detected") + case ProviderGitHub: + token, err := mintGitHubActionsTokenInternal(ctx, audience, getenv, httpClient) + if err != nil { + return "", "", err + } + return token, ProviderGitHub, nil + case ProviderGitLab: + token := strings.TrimSpace(getenv("CI_JOB_JWT_V2")) + if token == "" { + return "", "", fmt.Errorf("CI_JOB_JWT_V2 is not set; pass a GitLab id_tokens value with --token") + } + return token, ProviderGitLab, nil + case ProviderGeneric: + return "", "", fmt.Errorf("generic provider requires --token, --token-file, or --token-stdin") + default: + return "", "", fmt.Errorf("unsupported federated provider %q", provider) + } +} + +func mintGitHubActionsTokenInternal(ctx context.Context, audience string, getenv func(string) string, httpClient *http.Client) (string, error) { + requestURL := strings.TrimSpace(getenv("ACTIONS_ID_TOKEN_REQUEST_URL")) + requestToken := strings.TrimSpace(getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")) + if requestURL == "" || requestToken == "" { + return "", fmt.Errorf("GitHub Actions OIDC request environment is not set") + } + + parsedURL, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("invalid GitHub Actions OIDC request URL: %w", err) + } + if strings.TrimSpace(audience) != "" { + q := parsedURL.Query() + q.Set("audience", strings.TrimSpace(audience)) + parsedURL.RawQuery = q.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create GitHub Actions OIDC request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+requestToken) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to request GitHub Actions OIDC token: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return "", fmt.Errorf("failed to read GitHub Actions OIDC response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("GitHub Actions OIDC request failed with status %d", resp.StatusCode) + } + + var payload struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", fmt.Errorf("failed to parse GitHub Actions OIDC response: %w", err) + } + token := strings.TrimSpace(payload.Value) + if token == "" { + return "", fmt.Errorf("GitHub Actions OIDC response did not include a token") + } + return token, nil +} + +func normalizeFederatedProviderInternal(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return ProviderAuto + } + return provider +} diff --git a/cli/internal/ci/ci_detect_test.go b/cli/internal/ci/ci_detect_test.go new file mode 100644 index 0000000000..c33c08381d --- /dev/null +++ b/cli/internal/ci/ci_detect_test.go @@ -0,0 +1,64 @@ +package ci + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDetectFederatedTokenFromGitHubActionsInternal(t *testing.T) { + t.Parallel() + + var gotAudience string + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAudience = r.URL.Query().Get("audience") + gotAuth = r.Header.Get("Authorization") + require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"value": "github.jwt"})) + })) + t.Cleanup(server.Close) + + env := map[string]string{ + "ACTIONS_ID_TOKEN_REQUEST_URL": server.URL + "?api-version=2", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + } + token, provider, err := DetectToken(context.Background(), "auto", "https://arcane.example.com", envGetterFromMapInternal(env), server.Client()) + + require.NoError(t, err) + require.Equal(t, "github.jwt", token) + require.Equal(t, "github", provider) + require.Equal(t, "https://arcane.example.com", gotAudience) + require.Equal(t, "Bearer request-token", gotAuth) +} + +func TestDetectFederatedTokenFromGitLabLegacyInternal(t *testing.T) { + t.Parallel() + + token, provider, err := DetectToken(context.Background(), "auto", "https://arcane.example.com", envGetterFromMapInternal(map[string]string{ + "CI_JOB_JWT_V2": "gitlab.jwt", + }), http.DefaultClient) + + require.NoError(t, err) + require.Equal(t, "gitlab.jwt", token) + require.Equal(t, "gitlab", provider) +} + +func TestDetectFederatedTokenProviderMismatchInternal(t *testing.T) { + t.Parallel() + + _, _, err := DetectToken(context.Background(), "github", "aud", envGetterFromMapInternal(map[string]string{ + "CI_JOB_JWT_V2": "gitlab.jwt", + }), http.DefaultClient) + + require.Error(t, err) +} + +func envGetterFromMapInternal(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index 9908684314..c23935c6ce 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -32,6 +32,7 @@ import ( "net" "net/http" "net/url" + "os" "strings" "time" @@ -160,6 +161,11 @@ func NewFromConfig() (*Client, error) { if strings.TrimSpace(state.EnvOverride) != "" { cfg.DefaultEnvironment = strings.TrimSpace(state.EnvOverride) } + if token := strings.TrimSpace(os.Getenv("ARCANE_TOKEN")); token != "" { + cfg.JWTToken = token + cfg.APIKey = "" + cfg.RefreshToken = "" + } c, err := New(cfg) if err != nil { return nil, err @@ -180,6 +186,11 @@ func NewFromConfigUnauthenticated() (*Client, error) { if strings.TrimSpace(state.EnvOverride) != "" { cfg.DefaultEnvironment = strings.TrimSpace(state.EnvOverride) } + if token := strings.TrimSpace(os.Getenv("ARCANE_TOKEN")); token != "" { + cfg.JWTToken = token + cfg.APIKey = "" + cfg.RefreshToken = "" + } c, err := NewUnauthenticated(cfg) if err != nil { return nil, err diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 67d6582b58..e7379cec64 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -178,6 +178,7 @@ func Load() (*types.Config, error) { v.SetConfigType("yaml") v.SetDefault("server_url", "http://localhost:3552") v.SetDefault("default_environment", "0") + v.SetDefault("federated_audience", "") v.SetDefault("log_level", "info") if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) @@ -232,6 +233,9 @@ func Save(c *types.Config) error { if cfg.DefaultEnvironment != "" { v.Set("default_environment", cfg.DefaultEnvironment) } + if cfg.FederatedAudience != "" { + v.Set("federated_audience", cfg.FederatedAudience) + } if cfg.LogLevel != "" { v.Set("log_level", cfg.LogLevel) } @@ -301,6 +305,7 @@ func InitDefaultFile() (bool, error) { v.Set("jwt_token", "") v.Set("refresh_token", "") v.Set("default_environment", "0") + v.Set("federated_audience", "") v.Set("log_level", "info") v.Set("pagination.default.limit", defaultPaginationInitLimit) diff --git a/cli/internal/types/config.go b/cli/internal/types/config.go index 589d63bee4..0737b761b9 100644 --- a/cli/internal/types/config.go +++ b/cli/internal/types/config.go @@ -88,6 +88,8 @@ type Config struct { RefreshToken string `yaml:"refresh_token,omitempty" mapstructure:"refresh_token"` //nolint:gosec // persisted config schema requires this field name // DefaultEnvironment is the default environment ID to use DefaultEnvironment string `yaml:"default_environment,omitempty" mapstructure:"default_environment"` + // FederatedAudience is the default token audience for `arcane-cli auth federated` + FederatedAudience string `yaml:"federated_audience,omitempty" mapstructure:"federated_audience"` // LogLevel is the logging level (debug, info, warn, error, fatal, panic) LogLevel string `yaml:"log_level,omitempty" mapstructure:"log_level"` // CLIUpdateChannel controls which channel self-update uses (stable or next). diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index 4e55c50d59..7cc1233d83 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -9,10 +9,11 @@ type ArcaneApiEndpoints struct { VersionEndpoint string // Authentication - AuthLogoutEndpoint string - AuthMeEndpoint string - AuthPasswordEndpoint string - AuthRefreshEndpoint string + AuthLogoutEndpoint string + AuthMeEndpoint string + AuthPasswordEndpoint string + AuthRefreshEndpoint string + AuthFederatedEndpoint string // OIDC OIDCDeviceCodeEndpoint string @@ -178,10 +179,11 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut VersionEndpoint: "/api/version", // Authentication - AuthLogoutEndpoint: "/api/auth/logout", - AuthMeEndpoint: "/api/auth/me", - AuthPasswordEndpoint: "/api/auth/password", - AuthRefreshEndpoint: "/api/auth/refresh", + AuthLogoutEndpoint: "/api/auth/logout", + AuthMeEndpoint: "/api/auth/me", + AuthPasswordEndpoint: "/api/auth/password", + AuthRefreshEndpoint: "/api/auth/refresh", + AuthFederatedEndpoint: "/api/auth/federated/token", // OIDC OIDCDeviceCodeEndpoint: "/api/oidc/device/code", @@ -342,10 +344,11 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut } // Auth endpoints -func (e ArcaneApiEndpoints) AuthLogout() string { return e.AuthLogoutEndpoint } -func (e ArcaneApiEndpoints) AuthMe() string { return e.AuthMeEndpoint } -func (e ArcaneApiEndpoints) AuthPassword() string { return e.AuthPasswordEndpoint } -func (e ArcaneApiEndpoints) AuthRefresh() string { return e.AuthRefreshEndpoint } +func (e ArcaneApiEndpoints) AuthLogout() string { return e.AuthLogoutEndpoint } +func (e ArcaneApiEndpoints) AuthMe() string { return e.AuthMeEndpoint } +func (e ArcaneApiEndpoints) AuthPassword() string { return e.AuthPasswordEndpoint } +func (e ArcaneApiEndpoints) AuthRefresh() string { return e.AuthRefreshEndpoint } +func (e ArcaneApiEndpoints) AuthFederated() string { return e.AuthFederatedEndpoint } // OIDC endpoints func (e ArcaneApiEndpoints) OIDCDeviceCode() string { return e.OIDCDeviceCodeEndpoint } diff --git a/cli/pkg/auth/federated.go b/cli/pkg/auth/federated.go new file mode 100644 index 0000000000..9e6592b741 --- /dev/null +++ b/cli/pkg/auth/federated.go @@ -0,0 +1,233 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/getarcaneapp/arcane/cli/internal/ci" + "github.com/getarcaneapp/arcane/cli/internal/client" + "github.com/getarcaneapp/arcane/cli/internal/cmdutil" + "github.com/getarcaneapp/arcane/cli/internal/config" + "github.com/getarcaneapp/arcane/cli/internal/output" + "github.com/getarcaneapp/arcane/cli/internal/types" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" + "github.com/spf13/cobra" +) + +const maxFederatedErrorBody = 4096 + +var federatedCmd = &cobra.Command{ + Use: "federated", + Short: "Exchange a CI OIDC token for a short-lived Arcane token", + SilenceUsage: true, + Long: `Exchange an external OIDC token for a short-lived Arcane bearer token. + +GitHub Actions example: + permissions: { id-token: write, contents: read } + steps: + - run: | + eval "$(arcane-cli auth federated \ + --server https://arcane.example.com \ + --audience https://arcane.example.com --export)" + - run: arcane-cli projects up my-app`, + RunE: func(cmd *cobra.Command, args []string) error { + server, _ := cmd.Flags().GetString("server") + audience, _ := cmd.Flags().GetString("audience") + provider, _ := cmd.Flags().GetString("provider") + persist, _ := cmd.Flags().GetBool("persist") + exportOutput, _ := cmd.Flags().GetBool("export") + + if cmdutil.JSONOutputEnabled(cmd) && exportOutput { + return fmt.Errorf("--json and --export cannot be used together") + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if strings.TrimSpace(server) != "" { + cfg.ServerURL = strings.TrimSpace(server) + } + if strings.TrimSpace(audience) == "" { + audience = cfg.FederatedAudience + } + + subjectToken, tokenSource, err := resolveFederatedSubjectTokenInternal(cmd, provider, audience) + if err != nil { + return err + } + + c, err := client.NewUnauthenticated(cfg) + if err != nil { + return err + } + + tokenResp, err := exchangeFederatedTokenInternal(cmd, c, strings.TrimSpace(subjectToken), strings.TrimSpace(audience)) + if err != nil { + return err + } + if tokenResp.AccessToken == "" { + return fmt.Errorf("federated token exchange failed: empty access token") + } + + expiresAt := time.Now().UTC().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + if persist { + cfg.JWTToken = tokenResp.AccessToken + cfg.APIKey = "" + cfg.RefreshToken = "" + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save federated token: %w", err) + } + } + + switch { + case cmdutil.JSONOutputEnabled(cmd): + resultBytes, err := json.MarshalIndent(map[string]any{ + "token": tokenResp.AccessToken, + "tokenType": tokenResp.TokenType, + "expiresIn": tokenResp.ExpiresIn, + "expiresAt": expiresAt.Format(time.RFC3339), + "issuedTokenType": tokenResp.IssuedTokenType, + "source": tokenSource, + "persisted": persist, + }, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(resultBytes)) + case exportOutput: + fmt.Printf("export ARCANE_TOKEN=%s\n", shellQuoteInternal(tokenResp.AccessToken)) + fmt.Printf("export ARCANE_TOKEN_EXPIRES_AT=%s\n", shellQuoteInternal(expiresAt.Format(time.RFC3339))) + default: + output.Success("Federated token exchange successful") + output.KeyValue("Token source", tokenSource) + output.KeyValue("Expires at", expiresAt.Format(time.RFC3339)) + if persist { + path, _ := config.ConfigPath() + output.KeyValue("JWT token saved to config", path) + } else { + output.Info("Use --export, --json, or --persist to consume the token.") + } + } + + return nil + }, +} + +func init() { + AuthCmd.AddCommand(federatedCmd) + + federatedCmd.Flags().String("server", "", "Arcane server URL for this exchange") + federatedCmd.Flags().String("audience", "", "Audience to request from the external OIDC provider") + federatedCmd.Flags().String("token", "", "External OIDC token to exchange") + federatedCmd.Flags().String("token-file", "", "Path to a file containing the external OIDC token") + federatedCmd.Flags().Bool("token-stdin", false, "Read the external OIDC token from stdin") + federatedCmd.Flags().String("provider", ci.ProviderAuto, "OIDC token source: auto, github, gitlab, or generic") + federatedCmd.Flags().Bool("persist", false, "Persist the exchanged Arcane token in CLI config") + federatedCmd.Flags().Bool("export", false, "Print shell exports for ARCANE_TOKEN and ARCANE_TOKEN_EXPIRES_AT") + federatedCmd.Flags().Bool("json", false, "Output in JSON format") +} + +func resolveFederatedSubjectTokenInternal(cmd *cobra.Command, provider string, audience string) (string, string, error) { + token, _ := cmd.Flags().GetString("token") + if strings.TrimSpace(token) != "" { + return strings.TrimSpace(token), "flag", nil + } + + tokenFile, _ := cmd.Flags().GetString("token-file") + if strings.TrimSpace(tokenFile) != "" { + data, err := os.ReadFile(strings.TrimSpace(tokenFile)) + if err != nil { + return "", "", fmt.Errorf("failed to read token file: %w", err) + } + token = strings.TrimSpace(string(data)) + if token == "" { + return "", "", fmt.Errorf("token file is empty") + } + return token, "file", nil + } + + tokenStdin, _ := cmd.Flags().GetBool("token-stdin") + if tokenStdin || stdinHasDataInternal() { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", "", fmt.Errorf("failed to read token from stdin: %w", err) + } + token = strings.TrimSpace(string(data)) + if token == "" { + return "", "", fmt.Errorf("stdin token is empty") + } + return token, "stdin", nil + } + + return ci.DetectToken(cmd.Context(), provider, audience, os.Getenv, &http.Client{Timeout: 10 * time.Second}) +} + +func exchangeFederatedTokenInternal(cmd *cobra.Command, c *client.Client, subjectToken string, audience string) (*federatedtypes.FederatedTokenResponse, error) { + form := url.Values{} + form.Set("grant_type", federatedtypes.TokenExchangeGrantType) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", federatedtypes.SubjectTokenTypeJWT) + form.Set("requested_token_type", federatedtypes.RequestedTokenTypeAccessJWT) + if strings.TrimSpace(audience) != "" { + form.Set("audience", strings.TrimSpace(audience)) + } + + resp, err := c.RequestRaw(cmd.Context(), http.MethodPost, types.Endpoints.AuthFederated(), strings.NewReader(form.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + if err != nil { + return nil, fmt.Errorf("federated token exchange failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxFederatedErrorBody)) + if err != nil { + return nil, fmt.Errorf("failed to read federated token response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("federated token exchange failed (status %d): %s", resp.StatusCode, redactedFederatedExchangeMessageInternal(body)) + } + + var tokenResp federatedtypes.FederatedTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse federated token response: %w", err) + } + return &tokenResp, nil +} + +func redactedFederatedExchangeMessageInternal(body []byte) string { + var oauthErr struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` //nolint:tagliatelle // RFC 6749 wire shape is snake_case. + } + if err := json.Unmarshal(body, &oauthErr); err == nil { + if strings.TrimSpace(oauthErr.ErrorDescription) != "" { + return strings.TrimSpace(oauthErr.ErrorDescription) + } + if strings.TrimSpace(oauthErr.Error) != "" { + return strings.TrimSpace(oauthErr.Error) + } + } + msg := strings.TrimSpace(string(body)) + if msg == "" { + return "request rejected" + } + return msg +} + +func stdinHasDataInternal() bool { + info, err := os.Stdin.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) == 0 +} + +func shellQuoteInternal(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/cli/pkg/config/cmd.go b/cli/pkg/config/cmd.go index bf2a33103c..367c0d6db4 100644 --- a/cli/pkg/config/cmd.go +++ b/cli/pkg/config/cmd.go @@ -16,13 +16,14 @@ import ( ) var ( - setServerURL string - setAPIKey string - setJWTToken string - setEnvironment string - setLogLevel string - setDefaultLimit int - setResourceLimit []string + setServerURL string + setAPIKey string + setJWTToken string + setEnvironment string + setFederatedAudience string + setLogLevel string + setDefaultLimit int + setResourceLimit []string ) // ConfigCmd is the command for managing API configuration @@ -47,6 +48,7 @@ var configShowCmd = &cobra.Command{ fmt.Printf("JWT Token: %s\n", maskAPIKey(cfg.JWTToken)) fmt.Printf("Refresh Token: %s\n", maskAPIKey(cfg.RefreshToken)) fmt.Printf("Default Environment: %s\n", maskIfEmpty(cfg.DefaultEnvironment, "0 (local)")) + fmt.Printf("Federated Audience: %s\n", maskIfEmpty(cfg.FederatedAudience, "(not set)")) fmt.Printf("Log Level: %s\n", maskIfEmpty(cfg.LogLevel, "info (default)")) fmt.Printf("CLI Update Channel: %s\n", maskIfEmpty(cfg.CLIUpdateChannel, "(auto)")) fmt.Printf("Pagination Default: %s\n", maskIfEmpty(intToString(cfg.Pagination.Default.Limit), "(not set)")) @@ -273,6 +275,7 @@ func init() { configSetCmd.Flags().StringVar(&setAPIKey, "api-key", "", "API key for authentication") configSetCmd.Flags().StringVar(&setJWTToken, "jwt-token", "", "JWT access token for authentication (Bearer token)") configSetCmd.Flags().StringVar(&setEnvironment, "environment", "", "Default environment ID") + configSetCmd.Flags().StringVar(&setFederatedAudience, "federated-audience", "", "Default audience for federated authentication") configSetCmd.Flags().StringVar(&setLogLevel, "log-level", "", "Default log level (debug, info, warn, error)") configSetCmd.Flags().IntVar(&setDefaultLimit, "default-limit", 0, "Global default list limit for paginated resources (0 clears)") configSetCmd.Flags().StringSliceVar(&setResourceLimit, "resource-limit", nil, "Per-resource list limit in the form resource=limit (repeatable, 0 clears)") @@ -348,6 +351,12 @@ func applyConfigSetFlags(cmd *cobra.Command, cfg *clitypes.Config) (bool, error) updated = true } + if setFederatedAudience != "" { + cfg.FederatedAudience = setFederatedAudience + fmt.Printf("Set federated_audience = %s\n", setFederatedAudience) + updated = true + } + if setLogLevel != "" { cfg.LogLevel = setLogLevel fmt.Printf("Set log_level = %s\n", setLogLevel) @@ -431,6 +440,10 @@ func applyConfigSetArg(cfg *clitypes.Config, key, value string) (bool, error) { cfg.DefaultEnvironment = value fmt.Printf("Set default_environment = %s\n", value) return true, nil + case "federated-audience", "federated_audience", "audience": + cfg.FederatedAudience = value + fmt.Printf("Set federated_audience = %s\n", value) + return true, nil case "log-level", "loglevel", "log_level": cfg.LogLevel = value fmt.Printf("Set log_level = %s\n", value) @@ -481,7 +494,7 @@ func applyConfigSetArg(cfg *clitypes.Config, key, value string) (bool, error) { return applyResourceLimitByKey(cfg, key, resource, value) } - return false, fmt.Errorf("unknown config key %q. Supported keys include server-url, api-key, jwt-token, environment, log-level, default-limit, resource-limit, and pagination.resources..limit", key) + return false, fmt.Errorf("unknown config key %q. Supported keys include server-url, api-key, jwt-token, environment, federated-audience, log-level, default-limit, resource-limit, and pagination.resources..limit", key) } func applyResourceLimitByKey(cfg *clitypes.Config, key, resourceValue, limitValue string) (bool, error) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a4c3d9dc63..958d44b932 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2620,6 +2620,64 @@ "api_key_bulk_delete_failed": "Failed to delete {count} API key(s)", "api_key_bulk_delete_static_skipped": "Skipped {count} static API key(s). Only deletable keys will be removed.", "api_key_bulk_delete_static_only": "Static API keys cannot be deleted.", + "_comment_federated_credentials": "=== FEDERATED CREDENTIALS ===", + "federated_credential_page_title": "Federated Credentials", + "federated_credential_page_description": "Trust external OIDC workload identities and exchange them for short-lived Arcane tokens.", + "federated_credential_create_title": "Create Federated Credential", + "federated_credential_edit_title": "Edit Federated Credential", + "federated_credential_create_description": "Create a trust rule for a CI or workload OIDC identity.", + "federated_credential_edit_description": "Update the trust rule \"{name}\"", + "federated_credential_create_button": "Create Federated Credential", + "federated_credential_created_success": "Federated credential \"{name}\" created successfully", + "federated_credential_create_failed": "Failed to create federated credential \"{name}\"", + "federated_credential_updated_success": "Federated credential \"{name}\" updated successfully", + "federated_credential_update_failed": "Failed to update federated credential \"{name}\"", + "federated_credential_delete_title": "Delete Federated Credential \"{name}\"?", + "federated_credential_delete_message": "Are you sure you want to delete the federated credential \"{name}\"? This action cannot be undone.", + "federated_credential_delete_selected_title": "Delete {count} Federated Credential(s)?", + "federated_credential_delete_selected_message": "Are you sure you want to delete {count} federated credential(s)? This action cannot be undone.", + "federated_credential_delete_failed": "Failed to delete federated credential \"{name}\"", + "federated_credential_delete_success": "Federated credential \"{name}\" deleted successfully", + "federated_credential_bulk_delete_success": "Successfully deleted {count} federated credential(s)", + "federated_credential_bulk_delete_failed": "Failed to delete {count} federated credential(s)", + "federated_credential_name_label": "Name", + "federated_credential_name_placeholder": "GitHub Actions production deploy", + "federated_credential_description_placeholder": "Optional description", + "federated_credential_issuer_label": "Issuer", + "federated_credential_issuer_placeholder": "https://token.actions.githubusercontent.com", + "federated_credential_issuer_description": "The HTTPS OIDC issuer that signs the workload token.", + "federated_credential_issuer_required": "Enter a valid issuer URL", + "federated_credential_audiences_label": "Audiences", + "federated_credential_audiences_placeholder": "https://arcane.example.com", + "federated_credential_audiences_description": "One audience per line or comma. The external token must contain one of these audiences.", + "federated_credential_audience_required": "Enter at least one audience", + "federated_credential_subject_claim_label": "Subject Claim", + "federated_credential_subject_claim_placeholder": "sub", + "federated_credential_subject_match_label": "Subject Match", + "federated_credential_subject_match_placeholder": "repo:owner/repo:ref:refs/heads/main", + "federated_credential_subject_match_description": "Exact subject value or an anchored glob pattern.", + "federated_credential_subject_required": "Enter a subject match", + "federated_credential_match_type_label": "Match Type", + "federated_credential_match_exact": "Exact", + "federated_credential_match_glob": "Glob", + "federated_credential_role_required": "Select a role", + "federated_credential_role_scope_column": "Role / Scope", + "federated_credential_scope_global_option": "Global", + "federated_credential_ttl_label": "Token TTL", + "federated_credential_ttl_description": "Issued Arcane token lifetime in seconds.", + "federated_credential_ttl_min": "Token TTL must be at least 60 seconds", + "federated_credential_ttl_max": "Token TTL cannot exceed 3600 seconds", + "federated_credential_expires_at_label": "Credential Expires", + "federated_credential_expires_at_description": "Optional date when this trust rule stops accepting exchanges.", + "federated_credential_enabled_label": "Enabled", + "federated_credential_enabled_description": "Allow matching workloads to exchange tokens with this credential.", + "federated_credential_status_expired": "Expired", + "federated_credential_last_used": "Last Used", + "federated_credential_wildcard_warning_title": "Broad subject match", + "federated_credential_wildcard_warning_description": "Use the narrowest subject pattern that fits the workload.", + "federated_credential_instructions_title": "Federated Credential Created", + "federated_credential_instructions_description": "Use the Arcane CLI to request an external OIDC token and exchange it during automation.", + "federated_credential_instructions_snippet_label": "GitHub Actions snippet", "common_done": "Done", "common_important": "Important", "select_a_date": "Select a date", diff --git a/frontend/src/lib/components/sheets/federated-credential-form-sheet.svelte b/frontend/src/lib/components/sheets/federated-credential-form-sheet.svelte new file mode 100644 index 0000000000..332f37e771 --- /dev/null +++ b/frontend/src/lib/components/sheets/federated-credential-form-sheet.svelte @@ -0,0 +1,309 @@ + + + + {#snippet children()} +
+ + + + +
+ +
+ + + + {matchTypeLabel($inputs.matchType.value)} + + + {#each MATCH_TYPES as option (option.value)} + + {option.label} + + {/each} + + +
+
+ + {#if hasWildcardWarning} + + + {m.federated_credential_wildcard_warning_title()} + + {m.federated_credential_wildcard_warning_description()} + + + {/if} +
+
+ + + + {roleSelectedLabel($inputs.roleId.value)} + + + {#each roles as role (role.id)} + +
+ {role.name} + {#if role.description} + {role.description} + {/if} +
+
+ {/each} +
+
+ {#if $inputs.roleId.error} +

{$inputs.roleId.error}

+ {/if} +
+
+ + + + {envSelectedLabel($inputs.environmentId.value)} + + + {#each envOptions as option (option.id)} + + {option.name} + + {/each} + + +
+
+
+ + +
+
+ +
+ +

+ {m.federated_credential_enabled_description()} +

+
+
+ + {/snippet} + + {#snippet footer()} +
+ (open = false)} + disabled={isLoading} + /> + +
+ {/snippet} +
diff --git a/frontend/src/lib/query/query-keys.ts b/frontend/src/lib/query/query-keys.ts index 415b8900fb..0872ec87a1 100644 --- a/frontend/src/lib/query/query-keys.ts +++ b/frontend/src/lib/query/query-keys.ts @@ -32,6 +32,10 @@ export const queryKeys = { all: ['api-keys'] as const, list: (options: SearchPaginationSortRequest) => ['api-keys', stableSerialize(options)] as const }, + federatedCredentials: { + all: ['federated-credentials'] as const, + list: (options: SearchPaginationSortRequest) => ['federated-credentials', stableSerialize(options)] as const + }, environments: { all: ['environments'] as const, list: (options: SearchPaginationSortRequest) => ['environments', stableSerialize(options)] as const, diff --git a/frontend/src/lib/services/federated-credential-service.ts b/frontend/src/lib/services/federated-credential-service.ts new file mode 100644 index 0000000000..0fb0e30304 --- /dev/null +++ b/frontend/src/lib/services/federated-credential-service.ts @@ -0,0 +1,30 @@ +import BaseAPIService from './api-service'; +import type { CreateFederatedCredential, FederatedCredential, UpdateFederatedCredential } from '$lib/types/auth'; +import type { Paginated, SearchPaginationSortRequest } from '$lib/types/shared'; +import { transformPaginationParams } from '$lib/utils/tables'; + +export default class FederatedCredentialAPIService extends BaseAPIService { + async list(options?: SearchPaginationSortRequest): Promise> { + const params = transformPaginationParams(options); + const res = await this.api.get('/federated-credentials', { params }); + return res.data; + } + + async get(id: string): Promise { + return this.handleResponse(this.api.get(`/federated-credentials/${id}`)) as Promise; + } + + async create(credential: CreateFederatedCredential): Promise { + return this.handleResponse(this.api.post('/federated-credentials', credential)) as Promise; + } + + async update(id: string, credential: UpdateFederatedCredential): Promise { + return this.handleResponse(this.api.put(`/federated-credentials/${id}`, credential)) as Promise; + } + + async delete(id: string): Promise { + return this.handleResponse(this.api.delete(`/federated-credentials/${id}`)) as Promise; + } +} + +export const federatedCredentialService = new FederatedCredentialAPIService(); diff --git a/frontend/src/lib/types/auth.ts b/frontend/src/lib/types/auth.ts index c008fbe520..461fcd8291 100644 --- a/frontend/src/lib/types/auth.ts +++ b/frontend/src/lib/types/auth.ts @@ -198,3 +198,47 @@ export type UpdateApiKey = { expiresAt?: string; permissions?: ApiKeyPermissionGrant[]; }; + +// --- Federated credentials --- + +export type FederatedCredentialMatchType = 'exact' | 'glob'; + +export type FederatedCredential = { + id: string; + name: string; + description?: string; + enabled: boolean; + issuerUrl: string; + audiences: string[]; + subjectClaim: string; + subjectMatch: string; + matchType: FederatedCredentialMatchType; + roleId: string; + environmentId?: string; + identityUserId: string; + tokenTtlSeconds: number; + lastUsedAt?: string; + expiresAt?: string; + createdAt: string; + updatedAt?: string; + serviceUsername?: string; + roleName?: string; + environmentName?: string; +}; + +export type CreateFederatedCredential = { + name: string; + description?: string; + enabled: boolean; + issuerUrl: string; + audiences: string[]; + subjectClaim?: string; + subjectMatch: string; + matchType?: FederatedCredentialMatchType; + roleId: string; + environmentId?: string; + tokenTtlSeconds?: number; + expiresAt?: string; +}; + +export type UpdateFederatedCredential = Partial; diff --git a/frontend/src/routes/(app)/settings/authentication/+page.svelte b/frontend/src/routes/(app)/settings/authentication/+page.svelte index 9ce97ebfe3..6191914d72 100644 --- a/frontend/src/routes/(app)/settings/authentication/+page.svelte +++ b/frontend/src/routes/(app)/settings/authentication/+page.svelte @@ -18,7 +18,10 @@ import * as Collapsible from '$lib/components/ui/collapsible'; import SettingsRow from '$lib/components/settings/settings-row.svelte'; import { cn } from '$lib/utils'; + import * as Tabs from '$lib/components/ui/tabs/index.js'; + import { TabBar, type TabItem } from '$lib/components/tab-bar/index.js'; import OidcMappingTable from './oidc-mapping-table.svelte'; + import FederatedCredentialsTab from './federated-credentials-tab.svelte'; import OidcMappingFormSheet from '$lib/components/sheets/oidc-mapping-form-sheet.svelte'; import type { OidcRoleMapping, CreateOidcRoleMapping, UpdateOidcRoleMapping } from '$lib/types/auth'; import { oidcMappingService } from '$lib/services/oidc-mapping-service'; @@ -28,12 +31,40 @@ import IfPermitted from '$lib/components/if-permitted.svelte'; let { data }: PageProps = $props(); + type AuthenticationTab = 'settings' | 'federated'; + let activeTab: AuthenticationTab = $state(untrack(() => (data.activeAuthTab === 'federated' ? 'federated' : 'settings'))); + const formState = getContext('settingsFormState') as + | { + hasChanges: boolean; + isLoading: boolean; + saveFunction: (() => Promise) | null; + resetFunction: (() => void) | null; + } + | undefined; + + const authenticationTabItems = $derived.by( + () => + [ + { + value: 'settings', + label: m.authentication_title() + }, + { + value: 'federated', + label: m.federated_credential_page_title() + } + ] satisfies TabItem[] + ); + + function handleAuthenticationTabChange(value: string) { + activeTab = value === 'federated' ? 'federated' : 'settings'; + } // OIDC role mappings — co-located with the OIDC settings so admins // configure the groups claim and the mappings that read it in one place. - let oidcMappings = $state(untrack(() => data.oidcMappings ?? [])); + let oidcMappings: OidcRoleMapping[] = $state(untrack(() => data.oidcMappings ?? [])); let mappingSheetOpen = $state(false); - let editingMapping = $state(null); + let editingMapping: OidcRoleMapping | null = $state(null); let mappingSaving = $state(false); async function refreshMappings() { @@ -272,7 +303,6 @@ $effect(() => { settingsForm.registerFormActions(customSubmit, customReset); - const formState = getContext('settingsFormState') as any; if (formState) { formState.hasChanges = hasAuthenticationChanges; } @@ -283,307 +313,330 @@ title={m.authentication_title()} description={m.authentication_description()} icon={LockIcon} - pageType="form" - showReadOnlyTag={isReadOnly} + pageType={activeTab === 'settings' ? 'form' : 'management'} + showReadOnlyTag={activeTab === 'settings' && isReadOnly} > {#snippet mainContent()} -
-
-

{m.security_authentication_heading()}

- - {#if isAutoLoginEnabled} - - - {m.security_auto_login_enabled_title()} - - {m.security_auto_login_enabled_description()} - - - {:else} -
- - - - - - - {#snippet labelExtra()} - {#if isOidcEnvForced} -
- - - - {#if isOidcForcedDisabled} - {m.security_server_disabled_via_server()} - {:else} - {m.security_server_configured()} - {/if} - - - - {#if isOidcForcedDisabled} - {m.security_oidc_forced_disabled_tooltip()} - {:else} - {m.security_oidc_forced_managed_tooltip()} - {/if} - - -
- {/if} - {/snippet} -
+ + + + +
+
+

{m.security_authentication_heading()}

+ + {#if isAutoLoginEnabled} + + + {m.security_auto_login_enabled_title()} + + {m.security_auto_login_enabled_description()} + + + {:else} +
+ - {#if showOidcDetails} - - {oidcConfigOpen ? m.common_hide() : m.common_show()} {m.common_configuration()} - - - {/if} -
- - - {#if showOidcDetails} - -
-
- + + + + {#snippet labelExtra()} + {#if isOidcEnvForced} +
+ + + + {#if isOidcForcedDisabled} + {m.security_server_disabled_via_server()} + {:else} + {m.security_server_configured()} + {/if} + + + + {#if isOidcForcedDisabled} + {m.security_oidc_forced_disabled_tooltip()} + {:else} + {m.security_oidc_forced_managed_tooltip()} + {/if} + + +
+ {/if} + {/snippet} +
+ - + {#if showOidcDetails} + + {oidcConfigOpen ? m.common_hide() : m.common_show()} {m.common_configuration()} + + + {/if}
+
- - -
- - -
- - - - - -
- - +
+
+ + +
+ + - - - - + + +
+ + -
- - - - -
-
-
- - {m.oidc_redirect_uri_title()} -
-

{m.oidc_redirect_uri_description()}

-
- {redirectUri} - +
+ + + + + + + + + + + +
+ +
+
+ + {m.oidc_redirect_uri_title()} +
+

{m.oidc_redirect_uri_description()}

+
+ {redirectUri} + +
+
+ + {/if} + +
+ + +
+
+
+

{m.oidc_role_mappings_title()}

+

{m.oidc_role_mappings_description()}

+
- - {/if} - + {#if oidcMappings.length > 0} + + {:else} +
+

{m.oidc_mappings_empty_body()}

+
+ {/if} +
+
+ {/if}
- -
-
-
-

{m.oidc_role_mappings_title()}

-

{m.oidc_role_mappings_description()}

-
- -
- {#if oidcMappings.length > 0} - - {:else} -
-

{m.oidc_mappings_empty_body()}

-
- {/if} +
+

{m.security_session_heading()}

+
+
- - {/if} -
- -
-

{m.security_session_heading()}

-
- -
-
- -
-

{m.security_password_policy_label()}

- -
- - - ($formInputs.authPasswordPolicy.value = 'basic')} - customLabel={m.common_basic()} - /> - - - {m.security_password_policy_basic_tooltip()} - - - - - - ($formInputs.authPasswordPolicy.value = 'standard')} - customLabel={m.security_password_policy_standard()} - /> - - - {m.security_password_policy_standard_tooltip()} - - - - - - ($formInputs.authPasswordPolicy.value = 'strong')} - customLabel={m.security_password_policy_strong()} - /> - - - {m.security_password_policy_strong_tooltip()} - -
-
-
-
+ +
+

{m.security_password_policy_label()}

+ +
+ + + ($formInputs.authPasswordPolicy.value = 'basic')} + customLabel={m.common_basic()} + /> + + + {m.security_password_policy_basic_tooltip()} + + + + + + ($formInputs.authPasswordPolicy.value = 'standard')} + customLabel={m.security_password_policy_standard()} + /> + + + {m.security_password_policy_standard_tooltip()} + + + + + + ($formInputs.authPasswordPolicy.value = 'strong')} + customLabel={m.security_password_policy_strong()} + /> + + + {m.security_password_policy_strong_tooltip()} + + +
+
+
+
+ + + + + + {/snippet} {#snippet additionalContent()} diff --git a/frontend/src/routes/(app)/settings/authentication/+page.ts b/frontend/src/routes/(app)/settings/authentication/+page.ts index 285d4a0389..2ffb3de9ee 100644 --- a/frontend/src/routes/(app)/settings/authentication/+page.ts +++ b/frontend/src/routes/(app)/settings/authentication/+page.ts @@ -1,26 +1,59 @@ import { oidcMappingService } from '$lib/services/oidc-mapping-service'; +import { federatedCredentialService } from '$lib/services/federated-credential-service'; import { roleService } from '$lib/services/role-service'; import { environmentManagementService } from '$lib/services/env-mgmt-service'; +import { queryKeys } from '$lib/query/query-keys'; +import type { SearchPaginationSortRequest } from '$lib/types/shared'; +import { resolveInitialTableRequest } from '$lib/utils/tables'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ parent }) => { +export const load: PageLoad = async ({ parent, url }) => { const parentData = await parent(); + const { queryClient } = parentData; + const activeAuthTab = url.searchParams.get('tab') === 'federated' ? 'federated' : 'settings'; + const federatedCredentialRequestOptions = resolveInitialTableRequest('arcane-federated-credentials-table', { + pagination: { + page: 1, + limit: 20 + }, + sort: { + column: 'createdAt', + direction: 'desc' + } + } satisfies SearchPaginationSortRequest); // OIDC mappings live alongside the OIDC config in this page. We load them // here (instead of a standalone /settings/oidc-mappings route) so admins // configure groups claim + the mappings that read from it in one place. - const [mappings, roles, environmentsPage] = await Promise.all([ + const [mappings, federatedCredentials, roles, environmentsPage] = await Promise.all([ oidcMappingService.list(), - roleService.getAll(), - environmentManagementService.getEnvironments({ - pagination: { page: 1, limit: 1000 }, - sort: { column: 'name', direction: 'asc' } + queryClient.fetchQuery({ + queryKey: queryKeys.federatedCredentials.list(federatedCredentialRequestOptions), + queryFn: () => federatedCredentialService.list(federatedCredentialRequestOptions) + }), + queryClient.fetchQuery({ + queryKey: ['roles', 'all'], + queryFn: () => roleService.getAll() + }), + queryClient.fetchQuery({ + queryKey: queryKeys.environments.list({ + pagination: { page: 1, limit: 1000 }, + sort: { column: 'name', direction: 'asc' } + }), + queryFn: () => + environmentManagementService.getEnvironments({ + pagination: { page: 1, limit: 1000 }, + sort: { column: 'name', direction: 'asc' } + }) }) ]); return { ...parentData, + activeAuthTab, oidcMappings: mappings, + federatedCredentials, + federatedCredentialRequestOptions, roles, environments: environmentsPage.data }; diff --git a/frontend/src/routes/(app)/settings/authentication/federated-credential-table.svelte b/frontend/src/routes/(app)/settings/authentication/federated-credential-table.svelte new file mode 100644 index 0000000000..a7e9fdb9d4 --- /dev/null +++ b/frontend/src/routes/(app)/settings/authentication/federated-credential-table.svelte @@ -0,0 +1,290 @@ + + +{#snippet NameCell({ item }: { item: FederatedCredential })} + {item.name} +{/snippet} + +{#snippet IssuerCell({ item }: { item: FederatedCredential })} + {item.issuerUrl} +{/snippet} + +{#snippet SubjectCell({ item }: { item: FederatedCredential })} +
+ {item.subjectMatch} + {item.subjectClaim} / {item.matchType} +
+{/snippet} + +{#snippet RoleScopeCell({ item }: { item: FederatedCredential })} + {getRoleScope(item)} +{/snippet} + +{#snippet StatusCell({ item }: { item: FederatedCredential })} + +{/snippet} + +{#snippet LastUsedCell({ item }: { item: FederatedCredential })} + {formatDate(item.lastUsedAt)} +{/snippet} + +{#snippet FederatedCredentialMobileCardSnippet({ + item, + mobileFieldVisibility +}: { + item: FederatedCredential; + mobileFieldVisibility: MobileFieldVisibility; +})} + item.name} + subtitle={(item: FederatedCredential) => ((mobileFieldVisibility['issuerUrl'] ?? true) ? item.issuerUrl : null)} + badges={[ + (item: FederatedCredential) => ({ + variant: getStatusVariant(item), + text: getStatusText(item) + }) + ]} + fields={[ + { + label: m.federated_credential_subject_match_label(), + getValue: (item: FederatedCredential) => item.subjectMatch, + icon: LockIcon, + iconVariant: 'gray' as const, + show: mobileFieldVisibility['subjectMatch'] ?? true + }, + { + label: m.federated_credential_role_scope_column(), + getValue: (item: FederatedCredential) => getRoleScope(item), + icon: LockIcon, + iconVariant: 'gray' as const, + show: mobileFieldVisibility['roleScope'] ?? true + }, + { + label: m.federated_credential_last_used(), + getValue: (item: FederatedCredential) => formatDate(item.lastUsedAt), + icon: LockIcon, + iconVariant: 'gray' as const, + show: mobileFieldVisibility['lastUsedAt'] ?? true + } + ]} + rowActions={RowActions} + /> +{/snippet} + +{#snippet RowActions({ item }: { item: FederatedCredential })} + {#if canManageFederatedCredentials} + + + {#snippet child({ props })} + + {m.common_open_menu()} + + + {/snippet} + + + + onEditFederatedCredential(item)}> + + {m.common_edit()} + + + handleDeleteFederatedCredential(item.id, item.name)}> + + {m.common_delete()} + + + + + {/if} +{/snippet} + + { + requestOptions = options; + await onFederatedCredentialsChanged(); + return federatedCredentials; + }} + {columns} + {mobileFields} + rowActions={RowActions} + mobileCard={FederatedCredentialMobileCardSnippet} +/> diff --git a/frontend/src/routes/(app)/settings/authentication/federated-credentials-tab.svelte b/frontend/src/routes/(app)/settings/authentication/federated-credentials-tab.svelte new file mode 100644 index 0000000000..c88cec2f0f --- /dev/null +++ b/frontend/src/routes/(app)/settings/authentication/federated-credentials-tab.svelte @@ -0,0 +1,195 @@ + + + +
+
+
+

{m.federated_credential_page_title()}

+

{m.federated_credential_page_description()}

+
+ + + +
+ + +
+ + + + + + +
+
+

+ {m.federated_credential_instructions_snippet_label()} +

+ { + if (status === 'success') { + toast.success(m.common_copied()); + } + }} + /> +
+
+ {#snippet footer()} + (isDialogOpen.instructions = false)} customLabel={m.common_done()} /> + {/snippet} +
+ + {#snippet fallback()} +
+

{m.no_access_page_body()}

+
+ {/snippet} +
diff --git a/tests/setup/compose-proxy.yaml b/tests/setup/compose-proxy.yaml index ed18bf0ec3..ab944bcf5c 100644 --- a/tests/setup/compose-proxy.yaml +++ b/tests/setup/compose-proxy.yaml @@ -33,7 +33,9 @@ services: arcane: image: arcane:playwright-tests ports: - - '3002:3552' + - '${E2E_PROXY_PORT:-3002}:3552' + extra_hosts: + - 'host.docker.internal:host-gateway' environment: - ENVIRONMENT=testing - APP_ENV=test diff --git a/tests/spec/cli.spec.ts b/tests/spec/cli.spec.ts index 9ff56d79b7..46a9d8da2c 100644 --- a/tests/spec/cli.spec.ts +++ b/tests/spec/cli.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import playwrightConfig from '../playwright.config'; import { createCLIConfig, runCLI, runCLIJSON, type CLIConfig } from '../utils/cli.util'; +import { createMockOidcIssuer } from '../utils/oidc.util'; import { createTestApiKeys, deleteTestApiKeys } from '../utils/playwright.util'; type CreatedApiKey = { @@ -15,6 +16,28 @@ type PaginatedResponse = { pagination?: { totalItems?: number }; }; +type Role = { + id: string; + name: string; +}; + +type FederatedCredential = { + id: string; +}; + +type PlaywrightFederatedCredentialResponse = { + credential: FederatedCredential; +}; + +type FederatedAuthOutput = { + token: string; + tokenType: string; + expiresIn: number; + issuedTokenType: string; + source: string; + persisted: boolean; +}; + type JsonSmokeCommand = { name: string; args: string[]; @@ -45,6 +68,33 @@ async function runCommandJSON(configPath: string, args: string[]): Promise } } +async function arcaneAPI(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('X-API-KEY', apiKey); + if (init.body) { + headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json'); + } + + const response = await fetch(new URL(path, baseURL), { ...init, headers }); + if (!response.ok) { + throw new Error( + `${init.method ?? 'GET'} ${path} failed: ${response.status} ${await response.text()}` + ); + } + + return (await response.json()) as T; +} + +async function deleteFederatedCredential(id: string): Promise { + const response = await fetch(new URL(`/api/federated-credentials/${id}`, baseURL), { + method: 'DELETE', + headers: { 'X-API-KEY': apiKey } + }); + if (!response.ok && response.status !== 404) { + throw new Error(`failed to delete federated credential ${id}: ${response.status}`); + } +} + function expectPaginated(value: unknown): void { expect(value).toEqual( expect.objectContaining({ @@ -277,6 +327,84 @@ test.describe('arcane-cli e2e', () => { }); }); + test('auth federated exchanges a mock OIDC token and persists the CLI JWT', async () => { + const issuer = await createMockOidcIssuer(); + const config = await createCLIConfig(baseURL, ''); + const subject = `repo:getarcaneapp/arcane:ref:refs/heads/e2e-${Date.now()}`; + const audience = 'arcane-cli-e2e'; + let credentialID = ''; + + try { + const roles = await arcaneAPI>('/api/roles?limit=100'); + const viewerRole = roles.data.find((role) => role.id === 'role_viewer'); + expect(viewerRole).toBeTruthy(); + + const created = await arcaneAPI( + '/api/playwright/create-test-federated-credential', + { + method: 'POST', + body: JSON.stringify({ + issuerUrl: issuer.issuerURL, + audiences: [audience], + subject, + roleId: viewerRole!.id, + tokenTtlSeconds: 600 + }) + } + ); + credentialID = created.credential.id; + + const now = Math.floor(Date.now() / 1000); + const subjectToken = issuer.token({ + iss: issuer.issuerURL, + sub: subject, + aud: audience, + iat: now - 5, + nbf: now - 5, + exp: now + 300, + jti: `cli-e2e-${now}` + }); + + const exchange = await runCommandJSON(config.configPath, [ + 'auth', + 'federated', + '--server', + baseURL, + '--audience', + audience, + '--token', + subjectToken, + '--persist', + '--json' + ]); + + expect(exchange).toEqual( + expect.objectContaining({ + token: expect.any(String), + tokenType: 'Bearer', + expiresIn: expect.any(Number), + source: 'flag', + persisted: true + }) + ); + expect(exchange.expiresIn).toBeGreaterThan(0); + + const projects = await runCLIJSON>(config.configPath, [ + 'projects', + 'list', + '--limit', + '1' + ]); + expect(Array.isArray(projects.data)).toBe(true); + } finally { + if (credentialID) { + await deleteFederatedCredential(credentialID); + } + await config.cleanup(); + await issuer.close(); + } + }); + test('environments list and get return local environment JSON', async () => { await withConfig(async (config) => { const environments = await runCLIJSON>( diff --git a/tests/utils/oidc.util.ts b/tests/utils/oidc.util.ts new file mode 100644 index 0000000000..eb3648f003 --- /dev/null +++ b/tests/utils/oidc.util.ts @@ -0,0 +1,86 @@ +import crypto from 'node:crypto'; +import http from 'node:http'; + +type JwtClaims = Record; + +export type MockOidcIssuer = { + issuerURL: string; + token: (claims: JwtClaims) => string; + close: () => Promise; +}; + +function base64url(value: Buffer | string): string { + return Buffer.from(value).toString('base64url'); +} + +function signJWT(privateKey: crypto.KeyObject, keyID: string, claims: JwtClaims): string { + const header = base64url(JSON.stringify({ alg: 'RS256', kid: keyID, typ: 'JWT' })); + const payload = base64url(JSON.stringify(claims)); + const input = `${header}.${payload}`; + const signature = crypto.createSign('RSA-SHA256').update(input).sign(privateKey, 'base64url'); + + return `${input}.${signature}`; +} + +export async function createMockOidcIssuer(): Promise { + const keyID = `arcane-e2e-${crypto.randomUUID()}`; + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048 + }); + const publicJWK = publicKey.export({ format: 'jwk' }); + + let issuerURL = ''; + const server = http.createServer((req, res) => { + const path = req.url?.split('?')[0]; + + if (path === '/.well-known/openid-configuration') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + issuer: issuerURL, + jwks_uri: `${issuerURL}/jwks`, + authorization_endpoint: `${issuerURL}/authorize`, + token_endpoint: `${issuerURL}/token` + }) + ); + return; + } + + if (path === '/jwks') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + keys: [{ ...publicJWK, kid: keyID, use: 'sig', alg: 'RS256' }] + }) + ); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '0.0.0.0', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('mock OIDC issuer did not bind to a TCP port'); + } + + issuerURL = `http://host.docker.internal:${address.port}`; + + return { + issuerURL, + token: (claims) => signJWT(privateKey, keyID, claims), + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }) + }; +} diff --git a/types/federated/federated.go b/types/federated/federated.go new file mode 100644 index 0000000000..a112a5f466 --- /dev/null +++ b/types/federated/federated.go @@ -0,0 +1,93 @@ +// Package federated contains DTOs for OIDC workload identity federation. +package federated + +import "time" + +const ( + TokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" // #nosec G101: RFC 8693 grant type identifier, not a credential. + SubjectTokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" // #nosec G101: RFC 8693 token type identifier, not a credential. + SubjectTokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" // #nosec G101: RFC 8693 token type identifier, not a credential. + IssuedTokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" // #nosec G101: RFC 8693 token type identifier, not a credential. + RequestedTokenTypeAccessJWT = "urn:ietf:params:oauth:token-type:access_token" // #nosec G101: RFC 8693 token type identifier, not a credential. + + MatchTypeExact = "exact" + MatchTypeGlob = "glob" +) + +// FederatedCredential is a configured trust rule for one external OIDC +// workload identity subject. +type FederatedCredential struct { + ID string `json:"id" doc:"Unique identifier of the federated credential"` + Name string `json:"name" doc:"Display name"` + Description *string `json:"description,omitempty" doc:"Optional description"` + Enabled bool `json:"enabled" doc:"Whether exchanges are allowed"` + IssuerURL string `json:"issuerUrl" doc:"Trusted external OIDC issuer URL"` + Audiences []string `json:"audiences" doc:"Allowed external token audiences"` + SubjectClaim string `json:"subjectClaim" doc:"Claim path to match against"` + SubjectMatch string `json:"subjectMatch" doc:"Exact subject or anchored glob pattern"` + MatchType string `json:"matchType" doc:"Subject match strategy" enum:"exact,glob"` + RoleID string `json:"roleId" doc:"Mapped role ID"` + EnvironmentID *string `json:"environmentId,omitempty" doc:"Optional environment scope for the role assignment"` + IdentityUserID string `json:"identityUserId" doc:"Dedicated service user ID backing issued tokens"` + TokenTTLSeconds int `json:"tokenTtlSeconds" doc:"Issued token lifetime in seconds"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty" doc:"Last successful token exchange"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" doc:"Optional credential expiration"` + CreatedAt time.Time `json:"createdAt" doc:"Creation timestamp"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" doc:"Last update timestamp"` + ServiceUsername string `json:"serviceUsername,omitempty" doc:"Dedicated service account username"` + RoleName string `json:"roleName,omitempty" doc:"Mapped role name"` + EnvironmentName string `json:"environmentName,omitempty" doc:"Mapped environment name when scoped"` +} + +// CreateFederatedCredential is the request body for creating a federated +// workload identity credential. +type CreateFederatedCredential struct { + Name string `json:"name" minLength:"1" maxLength:"255" doc:"Display name"` + Description *string `json:"description,omitempty" maxLength:"1000" doc:"Optional description"` + Enabled bool `json:"enabled" doc:"Whether exchanges are allowed"` + IssuerURL string `json:"issuerUrl" minLength:"1" format:"uri" doc:"Trusted external OIDC issuer URL"` + Audiences []string `json:"audiences" minItems:"1" doc:"Allowed external token audiences"` + SubjectClaim string `json:"subjectClaim,omitempty" doc:"Claim path to match against; defaults to sub"` + SubjectMatch string `json:"subjectMatch" minLength:"1" doc:"Exact subject or anchored glob pattern"` + MatchType string `json:"matchType,omitempty" enum:"exact,glob" doc:"Subject match strategy"` + RoleID string `json:"roleId" minLength:"1" doc:"Mapped role ID"` + EnvironmentID *string `json:"environmentId,omitempty" doc:"Optional environment scope for the role assignment"` + TokenTTLSeconds int `json:"tokenTtlSeconds,omitempty" minimum:"60" maximum:"3600" doc:"Issued token lifetime in seconds"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" doc:"Optional credential expiration"` +} + +// UpdateFederatedCredential is the request body for updating a federated +// workload identity credential. +type UpdateFederatedCredential struct { + Name *string `json:"name,omitempty" maxLength:"255" doc:"Display name"` + Description *string `json:"description,omitempty" maxLength:"1000" doc:"Optional description"` + Enabled *bool `json:"enabled,omitempty" doc:"Whether exchanges are allowed"` + IssuerURL *string `json:"issuerUrl,omitempty" format:"uri" doc:"Trusted external OIDC issuer URL"` + Audiences []string `json:"audiences,omitempty" minItems:"1" doc:"Allowed external token audiences"` + SubjectClaim *string `json:"subjectClaim,omitempty" doc:"Claim path to match against"` + SubjectMatch *string `json:"subjectMatch,omitempty" minLength:"1" doc:"Exact subject or anchored glob pattern"` + MatchType *string `json:"matchType,omitempty" enum:"exact,glob" doc:"Subject match strategy"` + RoleID *string `json:"roleId,omitempty" minLength:"1" doc:"Mapped role ID"` + EnvironmentID *string `json:"environmentId,omitempty" doc:"Optional environment scope for the role assignment"` + TokenTTLSeconds *int `json:"tokenTtlSeconds,omitempty" minimum:"60" maximum:"3600" doc:"Issued token lifetime in seconds"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" doc:"Optional credential expiration"` +} + +// TokenExchangeRequest is the RFC 8693 token exchange form payload after +// server-side parsing. +type TokenExchangeRequest struct { + GrantType string + SubjectToken string + SubjectTokenType string + Audience string + Scope string + RequestedTokenType string +} + +// FederatedTokenResponse is the RFC 8693 successful token exchange response. +type FederatedTokenResponse struct { + AccessToken string `json:"access_token"` //nolint:tagliatelle // RFC 8693 wire shape is snake_case. + TokenType string `json:"token_type"` //nolint:tagliatelle // RFC 8693 wire shape is snake_case. + ExpiresIn int `json:"expires_in"` //nolint:tagliatelle // RFC 8693 wire shape is snake_case. + IssuedTokenType string `json:"issued_token_type"` //nolint:tagliatelle // RFC 8693 wire shape is snake_case. +} From accb6c597f6cfebe129fa5b7c4c9fdef0936cf58 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sat, 30 May 2026 17:21:23 -0500 Subject: [PATCH 24/40] fix: trivy scans not using arcane registries credentials (#2771) --- .../internal/bootstrap/services_bootstrap.go | 2 +- .../services/vulnerability_service.go | 131 +++++++++++++++++- .../services/vulnerability_service_test.go | 72 ++++++++++ 3 files changed, 198 insertions(+), 7 deletions(-) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 76463db637..43b259633e 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -79,7 +79,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event, svcs.Settings, svcs.ApiKey) svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker) svcs.Notification = services.NewNotificationService(db, cfg, svcs.Environment) - svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification, svcs.Activity) + svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification, svcs.Activity, svcs.ContainerRegistry) svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification, svcs.Activity) svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Vulnerability, svcs.Event) svcs.GitRepository = services.NewGitRepositoryService(db, cfg.GitWorkDir, svcs.Event, svcs.Settings) diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index 1e0331a87a..28919e4eb4 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -36,6 +37,7 @@ import ( containertypes "github.com/moby/moby/api/types/container" imagetypes "github.com/moby/moby/api/types/image" mounttypes "github.com/moby/moby/api/types/mount" + dockerregistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -66,6 +68,9 @@ const ( DefaultTrivyDBRepository = "ghcr.io/getarcaneapp/trivy-db:2" DefaultTrivyJavaDBRepository = "ghcr.io/getarcaneapp/trivy-java-db:1" DefaultTrivyChecksBundleRepository = "ghcr.io/getarcaneapp/trivy-checks:1" + trivyRegistryConfigDir = "/tmp/arcane-registry-auth" + trivyRegistryConfigCopyDest = "/tmp" + trivyRegistryConfigTarName = "arcane-registry-auth/config.json" ) type trivyRuntimeOptions struct { @@ -82,6 +87,7 @@ type VulnerabilityService struct { settingsService *SettingsService notificationService *NotificationService activityService *ActivityService + registryService *ContainerRegistryService // scanLocks provides per-image locking to allow concurrent scans of different images // while preventing duplicate scans of the same image scanLocks sync.Map // map[string]*sync.Mutex @@ -233,7 +239,7 @@ func (s *VulnerabilityService) getTrivyScanSlotChannelInternal(limit int) chan i } // NewVulnerabilityService creates a new VulnerabilityService instance -func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService, eventService *EventService, settingsService *SettingsService, notificationService *NotificationService, activityService *ActivityService) *VulnerabilityService { +func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService, eventService *EventService, settingsService *SettingsService, notificationService *NotificationService, activityService *ActivityService, registryService *ContainerRegistryService) *VulnerabilityService { return &VulnerabilityService{ db: db, dockerService: dockerService, @@ -241,10 +247,61 @@ func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService settingsService: settingsService, notificationService: notificationService, activityService: activityService, + registryService: registryService, trivyScanSlots: nil, } } +func buildDockerConfigJSONInternal(authConfigs map[string]dockerregistry.AuthConfig) ([]byte, error) { + if len(authConfigs) == 0 { + return nil, nil + } + + type authEntry struct { + Auth string `json:"auth"` + } + + auths := make(map[string]authEntry, len(authConfigs)) + for host, cfg := range authConfigs { + host = strings.TrimSpace(host) + if host == "" || cfg.Username == "" || cfg.Password == "" { + continue + } + + auths[host] = authEntry{ + Auth: base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.Password)), + } + } + + if len(auths) == 0 { + return nil, nil + } + + return json.Marshal(struct { + Auths map[string]authEntry `json:"auths"` + }{Auths: auths}) +} + +func (s *VulnerabilityService) buildTrivyRegistryConfigInternal(ctx context.Context) []byte { + if s.registryService == nil { + return nil + } + + authConfigs, err := s.registryService.GetAllRegistryAuthConfigs(ctx) + if err != nil { + slog.WarnContext(ctx, "failed to load registry credentials for trivy scan; continuing anonymously", "error", err) + return nil + } + + configJSON, err := buildDockerConfigJSONInternal(authConfigs) + if err != nil { + slog.WarnContext(ctx, "failed to build trivy registry config; continuing anonymously", "error", err) + return nil + } + + return configJSON +} + // ScanImage scans an image for vulnerabilities using Trivy func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imageID string, user models.User) (*vulnerability.ScanResult, error) { scanCtx := utils.ActivityRuntimeContext(ctx, nil) @@ -1438,10 +1495,18 @@ func (s *VulnerabilityService) createBatchTrivyContainer(ctx context.Context, tr return "", err } + registryConfigJSON := s.buildTrivyRegistryConfigInternal(ctx) + env := append([]string(nil), runtimeOptions.ContainerEnv...) + if len(registryConfigJSON) > 0 { + env = append(env, "DOCKER_CONFIG="+trivyRegistryConfigDir) + // ECR tokens are refreshed when the batch container is created; a multi-hour + // batch could outlive the token and fall back to anonymous pulls. + } + config := &containertypes.Config{ Image: trivyImage, Entrypoint: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, - Env: append([]string(nil), runtimeOptions.ContainerEnv...), + Env: env, Labels: map[string]string{ libarcane.InternalResourceLabel: "true", }, @@ -1453,7 +1518,7 @@ func (s *VulnerabilityService) createBatchTrivyContainer(ctx context.Context, tr resources, cpuSet, applyLimits := s.getTrivyContainerResourceOptionsInternal(ctx, dockerClient) applyTrivyContainerResourcesInternal(hostConfig, resources, cpuSet, applyLimits) - containerID, err := s.createAndStartTrivyContainerInternal(ctx, dockerClient, "batch", config, hostConfig) + containerID, err := s.createAndStartTrivyContainerInternal(ctx, dockerClient, "batch", config, hostConfig, registryConfigJSON) if err != nil { return "", err } @@ -2570,12 +2635,46 @@ func removeDockerContainerInternal(ctx context.Context, dockerClient *client.Cli } } +func copyRegistryConfigToContainerInternal(ctx context.Context, dockerClient *client.Client, containerID string, configJSON []byte) error { + if dockerClient == nil || containerID == "" || len(configJSON) == 0 { + return nil + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + if err := tw.WriteHeader(&tar.Header{Name: "arcane-registry-auth/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{Name: trivyRegistryConfigTarName, Mode: 0o644, Size: int64(len(configJSON))}); err != nil { + _ = tw.Close() + return err + } + if _, err := tw.Write(configJSON); err != nil { + _ = tw.Close() + return err + } + if err := tw.Close(); err != nil { + return err + } + + copyCtx, copyCancel := timeouts.WithTimeout(ctx, 0, timeouts.DefaultDockerAPI) + defer copyCancel() + + _, err := dockerClient.CopyToContainer(copyCtx, containerID, client.CopyToContainerOptions{ + DestinationPath: trivyRegistryConfigCopyDest, + Content: &buf, + }) + return err +} + func (s *VulnerabilityService) createAndStartTrivyContainerInternal( ctx context.Context, dockerClient *client.Client, scope string, config *containertypes.Config, hostConfig *containertypes.HostConfig, + registryConfigJSON []byte, ) (string, error) { logTrivyContainerStartupRequestInternal(ctx, scope, config, hostConfig) @@ -2591,6 +2690,19 @@ func (s *VulnerabilityService) createAndStartTrivyContainerInternal( return "", fmt.Errorf("failed to create container: %w", err) } + if len(registryConfigJSON) > 0 { + if err := copyRegistryConfigToContainerInternal(ctx, dockerClient, resp.ID, registryConfigJSON); err != nil { + // DOCKER_CONFIG may still point at the target directory. When the + // config file is absent, Trivy falls back to anonymous registry access. + slog.WarnContext(ctx, + "failed to inject registry credentials into trivy container; continuing anonymously", + "scope", scope, + "containerId", resp.ID, + "error", err, + ) + } + } + startCtx, startCancel := timeouts.WithTimeout(ctx, 0, timeouts.DefaultDockerAPI) defer startCancel() @@ -3129,6 +3241,7 @@ func (s *VulnerabilityService) runTrivyContainer( imageID string, config *containertypes.Config, hostConfig *containertypes.HostConfig, + registryConfigJSON []byte, ) (*os.File, *os.File, int64, int64, string, error) { stdoutFile, err := createTrivyLogTempFileInternal("trivy-stdout-*") if err != nil { @@ -3150,7 +3263,7 @@ func (s *VulnerabilityService) runTrivyContainer( } }() - containerID, err = s.createAndStartTrivyContainerInternal(ctx, dockerClient, "single-scan", config, hostConfig) + containerID, err = s.createAndStartTrivyContainerInternal(ctx, dockerClient, "single-scan", config, hostConfig, registryConfigJSON) if err != nil { return nil, nil, 0, 0, "", fmt.Errorf("failed to prepare trivy scan container: %w", err) } @@ -3334,7 +3447,13 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri return nil, err } - config := buildTrivyContainerConfig(trivyImage, cmdArgs, runtimeOptions.ContainerEnv) + registryConfigJSON := s.buildTrivyRegistryConfigInternal(ctx) + env := append([]string(nil), runtimeOptions.ContainerEnv...) + if len(registryConfigJSON) > 0 { + env = append(env, "DOCKER_CONFIG="+trivyRegistryConfigDir) + } + + config := buildTrivyContainerConfig(trivyImage, cmdArgs, env) resources, cpuSet, applyLimits := s.getTrivyContainerResourceOptionsInternal(ctx, dockerClient) securityOpts, privileged := s.getTrivyRuntimeSecurityInternal() hostConfig := buildTrivyHostConfig( @@ -3349,7 +3468,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri ) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) - stdoutFile, stderrFile, duration, statusCode, containerID, err := s.runTrivyContainer(ctx, dockerClient, imageID, config, hostConfig) + stdoutFile, stderrFile, duration, statusCode, containerID, err := s.runTrivyContainer(ctx, dockerClient, imageID, config, hostConfig, registryConfigJSON) if err != nil { return nil, err } diff --git a/backend/internal/services/vulnerability_service_test.go b/backend/internal/services/vulnerability_service_test.go index 6f5e9c2d80..13148d68ab 100644 --- a/backend/internal/services/vulnerability_service_test.go +++ b/backend/internal/services/vulnerability_service_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -21,10 +22,81 @@ import ( containertypes "github.com/moby/moby/api/types/container" mounttypes "github.com/moby/moby/api/types/mount" networktypes "github.com/moby/moby/api/types/network" + dockerregistry "github.com/moby/moby/api/types/registry" "github.com/stretchr/testify/require" "gorm.io/gorm" ) +func TestBuildDockerConfigJSONInternal(t *testing.T) { + tests := []struct { + name string + auths map[string]dockerregistry.AuthConfig + wantAuths map[string]string + }{ + { + name: "nil map", + }, + { + name: "empty map", + auths: map[string]dockerregistry.AuthConfig{}, + }, + { + name: "one registry", + auths: map[string]dockerregistry.AuthConfig{ + "registry.example.com": {Username: "user", Password: "pass"}, + }, + wantAuths: map[string]string{ + "registry.example.com": base64.StdEncoding.EncodeToString([]byte("user:pass")), + }, + }, + { + name: "skips blank credentials", + auths: map[string]dockerregistry.AuthConfig{ + "registry.example.com": {Username: "user", Password: "pass"}, + "blank-user.example": {Username: "", Password: "pass"}, + "blank-pass.example": {Username: "user", Password: ""}, + " ": {Username: "user", Password: "pass"}, + }, + wantAuths: map[string]string{ + "registry.example.com": base64.StdEncoding.EncodeToString([]byte("user:pass")), + }, + }, + { + name: "two registries", + auths: map[string]dockerregistry.AuthConfig{ + "registry-a.example.com": {Username: "user-a", Password: "pass-a"}, + "registry-b.example.com": {Username: "user-b", Password: "pass-b"}, + }, + wantAuths: map[string]string{ + "registry-a.example.com": base64.StdEncoding.EncodeToString([]byte("user-a:pass-a")), + "registry-b.example.com": base64.StdEncoding.EncodeToString([]byte("user-b:pass-b")), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildDockerConfigJSONInternal(tt.auths) + require.NoError(t, err) + if len(tt.wantAuths) == 0 { + require.Nil(t, got) + return + } + + var parsed struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + require.NoError(t, json.Unmarshal(got, &parsed)) + require.Len(t, parsed.Auths, len(tt.wantAuths)) + for host, want := range tt.wantAuths { + require.Equal(t, want, parsed.Auths[host].Auth) + } + }) + } +} + func TestIsExpectedDockerStreamEndErrorInternal(t *testing.T) { tests := []struct { name string From 68af389ff3a69d5e4b09f5a5781794cdd9c46fce Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sat, 30 May 2026 20:35:21 -0500 Subject: [PATCH 25/40] refactor: add new go linters and refactor functions (#2772) --- .github/.golangci.yml | 67 +++++++- backend/api/handlers/activities.go | 2 +- backend/api/handlers/apikeys.go | 2 +- backend/api/handlers/container_registries.go | 2 +- backend/api/handlers/containers.go | 12 +- backend/api/handlers/edge_mtls_ca.go | 11 +- backend/api/handlers/environments.go | 78 +++++---- backend/api/handlers/environments_test.go | 10 +- backend/api/handlers/federated.go | 2 +- backend/api/handlers/helpers.go | 2 +- backend/api/handlers/images.go | 2 +- backend/api/handlers/networks.go | 2 +- backend/api/handlers/oidc.go | 2 + backend/api/handlers/ports.go | 2 +- backend/api/handlers/projects.go | 16 +- backend/api/handlers/settings.go | 1 - backend/api/handlers/swarm.go | 5 +- backend/api/handlers/system.go | 2 +- backend/api/handlers/volumes.go | 6 +- backend/api/ws/handler.go | 38 +++-- backend/cli/upgrade/upgrade.go | 3 +- backend/frontend/frontend.go | 2 +- backend/internal/bootstrap/bootstrap.go | 22 +-- backend/internal/bootstrap/edge_bootstrap.go | 5 +- .../internal/bootstrap/router_bootstrap.go | 2 +- backend/internal/common/errors.go | 36 ++++- backend/internal/config/config.go | 16 +- backend/internal/configschema/schema.go | 3 +- backend/internal/database/database.go | 4 +- .../middleware/environment_middleware.go | 2 +- .../middleware/rate_limit_middleware.go | 2 +- backend/internal/models/activity.go | 6 +- backend/internal/models/api_key.go | 3 +- backend/internal/models/auto_update.go | 3 +- backend/internal/models/base.go | 4 +- backend/internal/models/container_registry.go | 3 +- backend/internal/models/environment.go | 4 +- backend/internal/models/event.go | 7 +- .../internal/models/federated_credential.go | 3 +- .../internal/models/federated_token_replay.go | 3 +- backend/internal/models/git_repository.go | 3 +- backend/internal/models/gitops_sync.go | 3 +- backend/internal/models/image_build.go | 3 +- backend/internal/models/image_update.go | 4 +- backend/internal/models/notification.go | 6 +- backend/internal/models/project.go | 4 +- backend/internal/models/rbac.go | 12 +- backend/internal/models/settings.go | 4 +- backend/internal/models/template.go | 2 + backend/internal/models/user.go | 3 +- backend/internal/models/user_session.go | 1 + backend/internal/models/volume_backup.go | 1 + .../internal/models/vulnerability_ignore.go | 4 +- backend/internal/models/vulnerability_scan.go | 4 +- backend/internal/models/webhook.go | 3 +- backend/internal/services/activity_service.go | 26 ++- .../services/activity_service_test.go | 2 +- backend/internal/services/api_key_service.go | 2 +- backend/internal/services/auth_service.go | 6 +- backend/internal/services/build_service.go | 16 +- .../services/build_workspace_service.go | 24 +-- .../services/container_registry_service.go | 4 +- .../internal/services/container_service.go | 4 +- .../services/container_service_test.go | 2 +- .../internal/services/dashboard_service.go | 3 +- .../services/docker_client_service.go | 2 +- .../internal/services/ecr_token_service.go | 6 +- .../internal/services/environment_service.go | 51 +++--- .../services/environment_service_test.go | 8 +- backend/internal/services/event_service.go | 18 +-- .../services/federated_credential_service.go | 3 +- .../services/git_repository_service.go | 11 +- .../internal/services/gitops_sync_service.go | 41 +++-- .../services/gitops_sync_service_test.go | 10 +- backend/internal/services/image_service.go | 4 +- .../internal/services/image_update_service.go | 17 +- backend/internal/services/job_service.go | 9 +- backend/internal/services/network_service.go | 7 +- .../internal/services/notification_service.go | 148 +++++++++--------- backend/internal/services/oidc_service.go | 19 ++- .../internal/services/playwright_service.go | 2 +- .../internal/services/port_service_test.go | 8 +- backend/internal/services/project_service.go | 141 +++++++---------- .../internal/services/project_service_test.go | 44 +++--- backend/internal/services/role_service.go | 6 +- backend/internal/services/settings_service.go | 14 +- backend/internal/services/swarm_service.go | 24 ++- .../internal/services/swarm_service_test.go | 6 +- backend/internal/services/system_service.go | 21 +-- backend/internal/services/template_service.go | 51 +++--- .../services/template_service_test.go | 4 +- backend/internal/services/updater_service.go | 24 ++- backend/internal/services/user_service.go | 19 +-- .../internal/services/user_service_test.go | 6 +- backend/internal/services/version_service.go | 12 +- backend/internal/services/volume_service.go | 43 +++-- .../internal/services/volume_service_test.go | 3 +- .../services/vulnerability_service.go | 96 +++++------- .../services/vulnerability_service_test.go | 2 +- backend/internal/services/webhook_service.go | 14 +- backend/pkg/authz/permissions.go | 11 +- backend/pkg/dockerutil/cgroup_utils.go | 4 +- backend/pkg/fswatch/watcher.go | 4 +- backend/pkg/gitutil/git.go | 8 +- backend/pkg/libarcane/activity/writer.go | 9 +- backend/pkg/libarcane/crypto/encryption.go | 9 +- backend/pkg/libarcane/docker_compat.go | 5 +- backend/pkg/libarcane/dockerrun/dockerrun.go | 3 +- backend/pkg/libarcane/edge/client.go | 27 ++-- .../libarcane/edge/client_transport_grpc.go | 5 +- .../libarcane/edge/client_transport_poll.go | 6 +- .../edge/client_transport_websocket.go | 3 +- backend/pkg/libarcane/edge/command_client.go | 13 +- backend/pkg/libarcane/edge/event_sync.go | 7 +- backend/pkg/libarcane/edge/proxy.go | 13 +- backend/pkg/libarcane/edge/remenv.go | 2 +- backend/pkg/libarcane/edge/server.go | 11 +- backend/pkg/libarcane/edge/tls.go | 73 +++++---- backend/pkg/libarcane/edge/tunnel.go | 12 +- .../libarcane/edge/tunnel_proto_adapter.go | 9 +- backend/pkg/libarcane/imageupdate/digest.go | 5 +- backend/pkg/libarcane/imageupdate/labels.go | 4 +- backend/pkg/libarcane/imageupdate/logfile.go | 5 +- .../libarcane/imageupdate/registry_http.go | 29 ++-- backend/pkg/libarcane/inspect_compat.go | 9 +- backend/pkg/libarcane/internal_resource.go | 2 +- .../pkg/libarcane/libbuild/build_context.go | 12 +- .../libarcane/libbuild/builder_buildkit.go | 4 +- .../libbuild/builder_buildkit_local.go | 3 +- .../pkg/libarcane/libbuild/builder_docker.go | 7 +- backend/pkg/libarcane/registryauth/helpers.go | 2 +- .../libarcane/registryauth/helpers_test.go | 6 +- .../pkg/libarcane/startup/runtime_identity.go | 6 +- .../libarcane/swarm/stack_deploy_engine.go | 21 ++- backend/pkg/libarcane/system/gpu.go | 21 +-- backend/pkg/libarcane/timeouts/timeouts.go | 4 +- backend/pkg/libarcane/ws/diagnostics.go | 1 + backend/pkg/pagination/filters.go | 2 +- backend/pkg/pagination/pagination.go | 4 +- backend/pkg/pagination/pagination_test.go | 22 +-- backend/pkg/pagination/params.go | 3 +- backend/pkg/pagination/search.go | 3 +- backend/pkg/pagination/skipcount_test.go | 12 +- backend/pkg/projects/compose.go | 1 + backend/pkg/projects/env.go | 5 +- backend/pkg/projects/fs_templates.go | 2 +- backend/pkg/projects/fs_util.go | 7 +- backend/pkg/projects/fs_writer.go | 10 +- backend/pkg/projects/includes.go | 12 +- backend/pkg/remenv/client.go | 13 +- backend/pkg/scheduler/analytics_job.go | 5 +- backend/pkg/utils/auth.go | 2 +- backend/pkg/utils/cache/cache_util.go | 8 +- backend/pkg/utils/httpx/outbound_url.go | 7 +- backend/pkg/utils/httpx/safe_remote.go | 8 +- backend/pkg/utils/jwtclaims/jwt.go | 13 +- .../pkg/utils/notifications/discord_sender.go | 5 +- .../pkg/utils/notifications/email_sender.go | 9 +- .../pkg/utils/notifications/generic_sender.go | 7 +- .../pkg/utils/notifications/gotify_sender.go | 8 +- .../pkg/utils/notifications/matrix_sender.go | 3 +- .../pkg/utils/notifications/ntfy_sender.go | 5 +- .../utils/notifications/pushover_sender.go | 13 +- .../pkg/utils/notifications/signal_sender.go | 13 +- .../pkg/utils/notifications/slack_sender.go | 5 +- .../utils/notifications/telegram_sender.go | 5 +- backend/pkg/utils/signals/signals.go | 2 +- cli/internal/ci/ci_detect.go | 11 +- cli/internal/client/client.go | 22 +-- cli/internal/cmdutil/context.go | 5 +- cli/internal/cmdutil/response.go | 5 +- cli/internal/output/output.go | 12 ++ cli/internal/prompt/prompt.go | 11 +- cli/internal/types/config.go | 12 +- cli/internal/types/endpoints.go | 24 +++ cli/pkg/admin/apikeys/cmd.go | 8 +- cli/pkg/admin/notifications/cmd.go | 5 +- cli/pkg/admin/roles/cmd.go | 18 ++- cli/pkg/admin/users/cmd.go | 4 +- cli/pkg/auth/cmd.go | 16 +- cli/pkg/auth/federated.go | 9 +- cli/pkg/config/cmd.go | 7 +- cli/pkg/containers/cmd.go | 9 +- cli/pkg/doctor/cmd.go | 5 +- cli/pkg/environments/cmd.go | 10 +- cli/pkg/generate/certs.go | 15 +- cli/pkg/gitops/cmd.go | 3 +- cli/pkg/images/cmd.go | 7 +- cli/pkg/images/updates/cmd.go | 11 +- cli/pkg/jobs/cmd.go | 3 +- cli/pkg/networks/cmd.go | 5 +- cli/pkg/projects/cmd.go | 12 +- cli/pkg/registries/cmd.go | 5 +- cli/pkg/repos/cmd.go | 8 +- cli/pkg/selfupdate/cmd.go | 5 +- cli/pkg/system/cmd.go | 3 +- cli/pkg/templates/cmd.go | 20 +-- cli/pkg/updater/cmd.go | 19 +-- cli/pkg/volumes/cmd.go | 5 +- types/apikey/apikey.go | 5 +- types/auth/auth.go | 8 +- types/auth/oidc.go | 8 +- types/base/json.go | 2 +- types/container/history.go | 1 + types/containerregistry/container_registry.go | 2 +- types/dockerinfo/docker_info.go | 10 +- types/environment/environment.go | 6 +- types/image/image.go | 7 +- types/meta/template_meta.go | 2 +- types/project/marshal_utils.go | 2 + types/swarm/service.go | 2 +- types/template/template.go | 2 +- types/user/user.go | 4 +- types/volume/browse.go | 1 + 214 files changed, 1309 insertions(+), 1093 deletions(-) diff --git a/.github/.golangci.yml b/.github/.golangci.yml index dd6d790e9e..18ba7272fc 100644 --- a/.github/.golangci.yml +++ b/.github/.golangci.yml @@ -5,46 +5,92 @@ run: linters: default: none enable: + - arangolint - asasalint - asciicheck - bidichk - bodyclose + - canonicalheader + - clickhouselint - contextcheck - copyloopvar + - decorder - dupl + - dupword - durationcheck + - embeddedstructfieldcheck - errcheck - errchkjson + - errname - errorlint - exhaustive + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - ginkgolinter - gocheckcompilerdirectives - gochecksumtype - gocognit - gocritic + - gocyclo + - godoclint + - goheader + - gomoddirectives + - gomodguard_v2 + - goprintffuncname - gosec - gosmopolitan - govet + - grouper + - iface + - importas + - inamedparam - ineffassign + - interfacebloat + - intrange + - iotamixing - loggercheck + - maintidx - makezero - - misspell - mirror + - misspell + - modernize - musttag - nakedret - nilerr - nilnesserr - noctx + - nolintlint + - nosprintfhostport + - perfsprint + - prealloc + - predeclared + - promlinter - protogetter - reassign - recvcheck + - revive - rowserrcheck + - sloglint - spancheck - sqlclosecheck - staticcheck + - testableexamples - testifylint + - unconvert + - unparam + - unqueryvet - unused - usestdlibvars + - usetesting + - wastedassign + - whitespace - zerologlint + settings: + gomoddirectives: + # replace directives (=> ../cli, => ../types). Allow them. + replace-local: true exclusions: generated: lax presets: @@ -52,6 +98,25 @@ linters: - common-false-positives - legacy - std-error-handling + rules: + # Initialism naming (ApiKey -> APIKey, AppUrl -> AppURL, etc.) is intentional, + # widespread, and API-surface; track separately rather than churn it here. + - linters: + - revive + text: "var-naming:" + # Unused params are required by interface/callback/gorm-hook signatures. + - linters: + - revive + text: "unused-parameter:" + # Package-prefixed exported names (swarm.SwarmInfo, port.PortMapping, etc.) are + # an intentional, monorepo-wide convention; renaming is a large public-API change. + - linters: + - revive + text: "exported:" + # The cli command package is a CLI entrypoint; printing to stdout is correct. + - linters: + - forbidigo + path: cli/ paths: - '.*\.pb\.go$' - third_party$ diff --git a/backend/api/handlers/activities.go b/backend/api/handlers/activities.go index c4be84a162..890584e1d5 100644 --- a/backend/api/handlers/activities.go +++ b/backend/api/handlers/activities.go @@ -256,7 +256,7 @@ func (h *ActivityHandler) streamLocalActivitiesInternal( ) { sendSnapshot := func() bool { activities, _, err := h.activityService.ListActivitiesPaginated(ctx, input.EnvironmentID, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Limit: resolveActivityStreamLimitInternal(input.Limit)}, + Params: pagination.Params{Limit: resolveActivityStreamLimitInternal(input.Limit)}, }) if err != nil { return false diff --git a/backend/api/handlers/apikeys.go b/backend/api/handlers/apikeys.go index c9e2148f6a..e6777ade06 100644 --- a/backend/api/handlers/apikeys.go +++ b/backend/api/handlers/apikeys.go @@ -210,7 +210,7 @@ func (h *ApiKeyHandler) ListApiKeys(ctx context.Context, input *ListApiKeysInput Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/handlers/container_registries.go b/backend/api/handlers/container_registries.go index 0124363b2f..020f97a845 100644 --- a/backend/api/handlers/container_registries.go +++ b/backend/api/handlers/container_registries.go @@ -452,7 +452,7 @@ func (h *ContainerRegistryHandler) SyncRegistries(ctx context.Context, input *Sy // Helper Methods // ============================================================================ -func (h *ContainerRegistryHandler) triggerRemoteRegistrySync(ctx context.Context, reason string) { //nolint:contextcheck // intentionally spawns background sync +func (h *ContainerRegistryHandler) triggerRemoteRegistrySync(ctx context.Context, reason string) { if h.environmentService == nil { return } diff --git a/backend/api/handlers/containers.go b/backend/api/handlers/containers.go index f6d577b14a..55611364f8 100644 --- a/backend/api/handlers/containers.go +++ b/backend/api/handlers/containers.go @@ -33,7 +33,7 @@ type ContainerHandler struct { appCtx context.Context } -// Paginated response +// ContainerPaginatedResponse is the paginated list response for containers. type ContainerPaginatedResponse struct { Success bool `json:"success"` Data []containertypes.Summary `json:"data"` @@ -130,7 +130,7 @@ type DeleteContainerOutput struct { Body ContainerActionResponse } -// RegisterContainers registers container endpoints. +// SetAutoUpdateInput is the request input for toggling container auto-update. type SetAutoUpdateInput struct { EnvironmentID string `path:"id" doc:"Environment ID"` ContainerID string `path:"containerId" doc:"Container ID"` @@ -275,7 +275,7 @@ func (h *ContainerHandler) ListContainers(ctx context.Context, input *ListContai Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -339,9 +339,9 @@ func (h *ContainerHandler) GetContainerStatusCounts(ctx context.Context, input * Body: ContainerStatusCountsResponse{ Success: true, Data: containertypes.StatusCounts{ - RunningContainers: int(running), - StoppedContainers: int(stopped), - TotalContainers: int(total), + RunningContainers: running, + StoppedContainers: stopped, + TotalContainers: total, }, }, }, nil diff --git a/backend/api/handlers/edge_mtls_ca.go b/backend/api/handlers/edge_mtls_ca.go index 1ad9aaa546..579ea151ab 100644 --- a/backend/api/handlers/edge_mtls_ca.go +++ b/backend/api/handlers/edge_mtls_ca.go @@ -3,6 +3,7 @@ package handlers import ( "crypto/x509" "encoding/pem" + "errors" "fmt" "math" "os" @@ -18,10 +19,10 @@ const edgeMTLSCertificateExpiryWarningWindow = 30 * 24 * time.Hour func generatedEdgeMTLSCAPathInternal(cfg *config.Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("config not available") + return "", errors.New("config not available") } if edge.NormalizeEdgeMTLSMode(cfg.EdgeMTLSMode) == edge.EdgeMTLSModeDisabled { - return "", fmt.Errorf("edge mTLS is disabled") + return "", errors.New("edge mTLS is disabled") } edgeCfg := &edge.Config{ @@ -48,10 +49,10 @@ func hasGeneratedEdgeMTLSCAInternal(cfg *config.Config) bool { func generatedEdgeMTLSClientCertPathInternal(cfg *config.Config, envID string) (string, error) { if cfg == nil { - return "", fmt.Errorf("config not available") + return "", errors.New("config not available") } if edge.NormalizeEdgeMTLSMode(cfg.EdgeMTLSMode) == edge.EdgeMTLSModeDisabled { - return "", fmt.Errorf("edge mTLS is disabled") + return "", errors.New("edge mTLS is disabled") } edgeCfg := &edge.Config{ @@ -82,7 +83,7 @@ func readGeneratedEdgeMTLSCertificateInfoInternal(cfg *config.Config, envID stri block, _ := pem.Decode(certPEM) if block == nil { - return nil, fmt.Errorf("decode generated edge mTLS client certificate PEM") + return nil, errors.New("decode generated edge mTLS client certificate PEM") } cert, err := x509.ParseCertificate(block.Bytes) diff --git a/backend/api/handlers/environments.go b/backend/api/handlers/environments.go index f7af5b6735..dd1523b178 100644 --- a/backend/api/handlers/environments.go +++ b/backend/api/handlers/environments.go @@ -72,7 +72,8 @@ type CreateEnvironmentInput struct { type EnvironmentWithApiKey struct { environment.Environment - ApiKey *string `json:"apiKey,omitempty" doc:"API key for pairing (only shown once during creation)"` //nolint:gosec // response schema requires apiKey naming + + ApiKey *string `json:"apiKey,omitempty" doc:"API key for pairing (only shown once during creation)"` } type CreateEnvironmentOutput struct { @@ -435,7 +436,7 @@ func (h *EnvironmentHandler) ListEnvironments(ctx context.Context, input *ListEn Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -450,7 +451,7 @@ func (h *EnvironmentHandler) ListEnvironments(ctx context.Context, input *ListEn return nil, huma.Error500InternalServerError((&common.EnvironmentListError{Err: err}).Error()) } for i := range envs { - h.applyEdgeRuntimeState(&envs[i]) + h.applyEdgeRuntimeStateInternal(&envs[i]) } return &ListEnvironmentsOutput{ @@ -497,13 +498,13 @@ func (h *EnvironmentHandler) CreateEnvironment(ctx context.Context, input *Creat useApiKey := input.Body.UseApiKey != nil && *input.Body.UseApiKey if useApiKey { - return h.createEnvironmentWithApiKey(ctx, env, user) + return h.createEnvironmentWithApiKeyInternal(ctx, env, user) } - return h.createEnvironmentLegacy(ctx, env, user, input.Body) + return h.createEnvironmentLegacyInternal(ctx, env, user, input.Body) } -func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, env *models.Environment, user *models.User) (*CreateEnvironmentOutput, error) { +func (h *EnvironmentHandler) createEnvironmentWithApiKeyInternal(ctx context.Context, env *models.Environment, user *models.User) (*CreateEnvironmentOutput, error) { // New API key-based pairing flow env.Status = string(models.EnvironmentStatusPending) @@ -537,7 +538,7 @@ func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, en if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) return &CreateEnvironmentOutput{ Body: base.ApiResponse[EnvironmentWithApiKey]{ @@ -550,7 +551,7 @@ func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, en }, nil } -func (h *EnvironmentHandler) createEnvironmentLegacy(ctx context.Context, env *models.Environment, user *models.User, body environment.Create) (*CreateEnvironmentOutput, error) { +func (h *EnvironmentHandler) createEnvironmentLegacyInternal(ctx context.Context, env *models.Environment, user *models.User, body environment.Create) (*CreateEnvironmentOutput, error) { if body.AccessToken != nil && *body.AccessToken != "" { env.AccessToken = body.AccessToken } @@ -562,14 +563,14 @@ func (h *EnvironmentHandler) createEnvironmentLegacy(ctx context.Context, env *m // Sync registries and git repositories in background (intentionally detached from request context) if created.AccessToken != nil && *created.AccessToken != "" { - h.triggerEnvironmentResourceSync(ctx, created.ID, created.Name, "environment creation") + h.triggerEnvironmentResourceSyncInternal(ctx, created.ID, created.Name, "environment creation") } out, mapErr := mapper.MapOne[*models.Environment, environment.Environment](created) if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) return &CreateEnvironmentOutput{ Body: base.ApiResponse[EnvironmentWithApiKey]{ @@ -596,7 +597,7 @@ func (h *EnvironmentHandler) GetEnvironment(ctx context.Context, input *GetEnvir if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) if env.IsEdge { if certInfo, certErr := readGeneratedEdgeMTLSCertificateInfoInternal(h.cfg, env.ID); certErr == nil { out.EdgeMTLSCertificate = certInfo @@ -618,12 +619,9 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat } isLocalEnv := input.ID == localDockerEnvironmentID - updates := h.buildUpdateMap(&input.Body, isLocalEnv) + updates := h.buildUpdateMapInternal(&input.Body, isLocalEnv) - pairingSucceeded, err := h.handleEnvironmentPairing(ctx, input.ID, &input.Body, updates, isLocalEnv) - if err != nil { - return nil, err - } + h.handleEnvironmentPairingInternal(ctx, input.ID, &input.Body, updates, isLocalEnv) user, _ := humamw.GetCurrentUserFromContext(ctx) var userID, username *string @@ -636,13 +634,13 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat return nil, huma.Error500InternalServerError((&common.EnvironmentUpdateError{Err: updateErr}).Error()) } - h.triggerPostUpdateTasks(ctx, input.ID, updated, pairingSucceeded, &input.Body) //nolint:contextcheck // intentionally detached background tasks + h.triggerPostUpdateTasksInternal(ctx, input.ID, updated, &input.Body) out, mapErr := mapper.MapOne[*models.Environment, environment.Environment](updated) if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) // If regenerating API key, return the new key var newApiKey *string @@ -684,7 +682,7 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) newApiKey = new(apiKeyDto.Key) } @@ -700,7 +698,7 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat }, nil } -func (h *EnvironmentHandler) applyEdgeRuntimeState(env *environment.Environment) { +func (h *EnvironmentHandler) applyEdgeRuntimeStateInternal(env *environment.Environment) { services.ApplyEnvironmentRuntimeState(env) } @@ -844,7 +842,7 @@ func (h *EnvironmentHandler) SyncEnvironment(ctx context.Context, input *SyncEnv // Helper Methods // ============================================================================ -func (h *EnvironmentHandler) buildUpdateMap(req *environment.Update, isLocalEnv bool) map[string]any { +func (h *EnvironmentHandler) buildUpdateMapInternal(req *environment.Update, isLocalEnv bool) map[string]any { updates := map[string]any{} if !isLocalEnv { @@ -863,21 +861,19 @@ func (h *EnvironmentHandler) buildUpdateMap(req *environment.Update, isLocalEnv return updates } -func (h *EnvironmentHandler) handleEnvironmentPairing(ctx context.Context, environmentID string, req *environment.Update, updates map[string]any, isLocalEnv bool) (bool, error) { +func (h *EnvironmentHandler) handleEnvironmentPairingInternal(ctx context.Context, environmentID string, req *environment.Update, updates map[string]any, isLocalEnv bool) { _ = ctx _ = environmentID if isLocalEnv { - return false, nil + return } if req.AccessToken != nil { updates["access_token"] = *req.AccessToken } - - return false, nil } -func (h *EnvironmentHandler) triggerPostUpdateTasks(ctx context.Context, environmentID string, updated *models.Environment, pairingSucceeded bool, req *environment.Update) { //nolint:contextcheck // intentionally spawns background tasks +func (h *EnvironmentHandler) triggerPostUpdateTasksInternal(ctx context.Context, environmentID string, updated *models.Environment, req *environment.Update) { if updated.Enabled { detachedCtx := context.WithoutCancel(ctx) go func(syncCtx context.Context, envID string, envName string) { @@ -889,12 +885,12 @@ func (h *EnvironmentHandler) triggerPostUpdateTasks(ctx context.Context, environ }(detachedCtx, environmentID, updated.Name) } - if updated.AccessToken != nil && *updated.AccessToken != "" && (pairingSucceeded || (req.AccessToken != nil && *req.AccessToken != "") || req.Name != nil) { - h.triggerEnvironmentResourceSync(ctx, environmentID, updated.Name, "environment update") + if updated.AccessToken != nil && *updated.AccessToken != "" && ((req.AccessToken != nil && *req.AccessToken != "") || req.Name != nil) { + h.triggerEnvironmentResourceSyncInternal(ctx, environmentID, updated.Name, "environment update") } } -func (h *EnvironmentHandler) triggerEnvironmentResourceSync(ctx context.Context, environmentID string, environmentName string, reason string) { //nolint:contextcheck // intentionally spawns background tasks +func (h *EnvironmentHandler) triggerEnvironmentResourceSyncInternal(ctx context.Context, environmentID string, environmentName string, reason string) { detachedCtx := context.WithoutCancel(ctx) go func(syncCtx context.Context, envID string, envName string, syncReason string) { @@ -958,7 +954,7 @@ func (h *EnvironmentHandler) PairEnvironment(ctx context.Context, input *PairEnv } slog.InfoContext(ctx, "Environment pairing completed", "environmentID", *envID, "environmentName", env.Name) - h.triggerEnvironmentResourceSync(ctx, *envID, env.Name, "environment pairing") + h.triggerEnvironmentResourceSyncInternal(ctx, *envID, env.Name, "environment pairing") return &PairEnvironmentOutput{ Body: base.ApiResponse[base.MessageResponse]{ @@ -1096,7 +1092,7 @@ func (h *EnvironmentHandler) GetEnvironmentVersion(ctx context.Context, input *G } client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) //nolint:gosec // intentional request to configured remote environment API URL + resp, err := client.Do(req) if err != nil { return nil, huma.Error500InternalServerError("Request failed: " + err.Error()) } @@ -1153,7 +1149,7 @@ func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *Download slog.WarnContext(humaCtx.Context(), "Failed to stream edge mTLS CA download", "fileName", fileName, "bytesWritten", written, "bytesExpected", len(caPEM), "error", writeErr) return } - h.logMTLSAuditEvent(humaCtx.Context(), nil, models.EventTypeEnvironmentMTLSDownload, + h.logMTLSAuditEventInternal(humaCtx.Context(), nil, models.EventTypeEnvironmentMTLSDownload, "mTLS CA downloaded", fmt.Sprintf("Administrator downloaded edge mTLS CA %q", fileName), models.JSON{ @@ -1165,7 +1161,7 @@ func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *Download } func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, input *DownloadEnvironmentMTLSBundleInput) (*huma.StreamResponse, error) { - env, files, err := h.loadEnvironmentMTLSFiles(ctx, input.ID) + env, files, err := h.loadEnvironmentMTLSFilesInternal(ctx, input.ID) if err != nil { return nil, err } @@ -1210,7 +1206,7 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, slog.WarnContext(humaCtx.Context(), "Failed to stream edge mTLS bundle download", "environmentID", input.ID, "fileName", fileName, "bytesWritten", written, "bytesExpected", archive.Len(), "error", writeErr) return } - h.logMTLSAuditEvent(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, + h.logMTLSAuditEventInternal(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, "mTLS bundle downloaded", fmt.Sprintf("Administrator downloaded edge mTLS bundle %q (%d files)", fileName, len(files)), models.JSON{ @@ -1223,7 +1219,7 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, } func (h *EnvironmentHandler) DownloadEnvironmentMTLSFile(ctx context.Context, input *DownloadEnvironmentMTLSFileInput) (*huma.StreamResponse, error) { - env, file, err := h.loadEnvironmentMTLSFile(ctx, input.ID, input.FileName) + env, file, err := h.loadEnvironmentMTLSFileInternal(ctx, input.ID, input.FileName) if err != nil { return nil, err } @@ -1241,7 +1237,7 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSFile(ctx context.Context, in slog.WarnContext(humaCtx.Context(), "Failed to stream edge mTLS asset download", "environmentID", input.ID, "fileName", file.Name, "bytesWritten", written, "bytesExpected", len(fileContent), "error", writeErr) return } - h.logMTLSAuditEvent(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, + h.logMTLSAuditEventInternal(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, "mTLS asset downloaded", fmt.Sprintf("Administrator downloaded edge mTLS asset %q", file.Name), models.JSON{ @@ -1278,7 +1274,7 @@ func (h *EnvironmentHandler) loadEnvironmentMTLSEnvironmentInternal(ctx context. return env, nil } -func (h *EnvironmentHandler) loadEnvironmentMTLSFiles(ctx context.Context, environmentID string) (*models.Environment, []services.DeploymentSnippetFile, error) { +func (h *EnvironmentHandler) loadEnvironmentMTLSFilesInternal(ctx context.Context, environmentID string) (*models.Environment, []services.DeploymentSnippetFile, error) { env, err := h.loadEnvironmentMTLSEnvironmentInternal(ctx, environmentID) if err != nil { return nil, nil, err @@ -1302,8 +1298,8 @@ func (h *EnvironmentHandler) loadEnvironmentMTLSFiles(ctx context.Context, envir return env, snippets.MTLS.Files, nil } -func (h *EnvironmentHandler) loadEnvironmentMTLSFile(ctx context.Context, environmentID string, fileName string) (*models.Environment, services.DeploymentSnippetFile, error) { - env, files, err := h.loadEnvironmentMTLSFiles(ctx, environmentID) +func (h *EnvironmentHandler) loadEnvironmentMTLSFileInternal(ctx context.Context, environmentID string, fileName string) (*models.Environment, services.DeploymentSnippetFile, error) { + env, files, err := h.loadEnvironmentMTLSFilesInternal(ctx, environmentID) if err != nil { return nil, services.DeploymentSnippetFile{}, err } @@ -1376,10 +1372,10 @@ func environmentMTLSAssetFileModeInternal(file services.DeploymentSnippetFile) o return 0o644 } -// logMTLSAuditEvent records an audit event for administrator-triggered +// logMTLSAuditEventInternal records an audit event for administrator-triggered // edge mTLS actions (downloads, bundle exports). Must never include raw // certificate content or private key material. -func (h *EnvironmentHandler) logMTLSAuditEvent(ctx context.Context, env *models.Environment, eventType models.EventType, title, description string, extra models.JSON) { +func (h *EnvironmentHandler) logMTLSAuditEventInternal(ctx context.Context, env *models.Environment, eventType models.EventType, title, description string, extra models.JSON) { if h == nil || h.eventService == nil { return } diff --git a/backend/api/handlers/environments_test.go b/backend/api/handlers/environments_test.go index c05daef361..b2dcd19e84 100644 --- a/backend/api/handlers/environments_test.go +++ b/backend/api/handlers/environments_test.go @@ -43,7 +43,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: false, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusOnline), env.Status) assert.Nil(t, env.EdgeTransport) @@ -68,7 +68,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusOffline), env.Status) assert.Nil(t, env.EdgeTransport) @@ -95,7 +95,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusPending), env.Status) assert.Nil(t, env.EdgeTransport) @@ -129,7 +129,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusOnline), env.Status) if assert.NotNil(t, env.EdgeTransport) { @@ -166,7 +166,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusStandby), env.Status) if assert.NotNil(t, env.Connected) { diff --git a/backend/api/handlers/federated.go b/backend/api/handlers/federated.go index 3e6c6bec95..b33c207296 100644 --- a/backend/api/handlers/federated.go +++ b/backend/api/handlers/federated.go @@ -193,7 +193,7 @@ func (h *FederatedCredentialHandler) ListFederatedCredentials(ctx context.Contex Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/handlers/helpers.go b/backend/api/handlers/helpers.go index ffb700bf1d..8552ca6d3f 100644 --- a/backend/api/handlers/helpers.go +++ b/backend/api/handlers/helpers.go @@ -50,7 +50,7 @@ func buildPaginationParamsInternal(start, limit int, sortCol, sortDir, search st Sort: sortCol, Order: pagination.SortOrder(sortDir), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: start, Limit: limit, }, diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index c214f1bfde..59b1a74260 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -347,7 +347,7 @@ func (h *ImageHandler) ListImages(ctx context.Context, input *ListImagesInput) ( Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/handlers/networks.go b/backend/api/handlers/networks.go index 20c06484d0..e39f7bf0c4 100644 --- a/backend/api/handlers/networks.go +++ b/backend/api/handlers/networks.go @@ -231,7 +231,7 @@ func (h *NetworkHandler) ListNetworks(ctx context.Context, input *ListNetworksIn Sort: strings.TrimSpace(input.Sort), Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/handlers/oidc.go b/backend/api/handlers/oidc.go index 39d090d7df..cce1b77cf1 100644 --- a/backend/api/handlers/oidc.go +++ b/backend/api/handlers/oidc.go @@ -48,6 +48,7 @@ type GetOidcStatusOutput struct { type GetOidcAuthUrlInput struct { OidcHeaders + Body auth.OidcAuthUrlRequest } @@ -58,6 +59,7 @@ type GetOidcAuthUrlOutput struct { type HandleOidcCallbackInput struct { OidcHeaders + OidcStateCookie string `cookie:"oidc_state" doc:"OIDC state cookie from auth URL request"` Body auth.OidcCallbackRequest } diff --git a/backend/api/handlers/ports.go b/backend/api/handlers/ports.go index 8523341ae9..06acd90f3d 100644 --- a/backend/api/handlers/ports.go +++ b/backend/api/handlers/ports.go @@ -64,7 +64,7 @@ func (h *PortHandler) ListPorts(ctx context.Context, input *ListPortsInput) (*Li Sort: strings.TrimSpace(input.Sort), Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index fa49dfee3e..d302782b3b 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -208,6 +208,8 @@ type PullProgressEvent struct { // RegisterProjects registers project management routes using Huma. // Note: WebSocket and streaming endpoints remain as Gin handlers. +// +//nolint:maintidx // long but flat Huma route-registration function; complexity is sequential, not branching func RegisterProjects(api huma.API, projectService *services.ProjectService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &ProjectHandler{ projectService: projectService, @@ -521,7 +523,7 @@ func (h *ProjectHandler) ListProjects(ctx context.Context, input *ListProjectsIn Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -570,10 +572,10 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP Body: base.ApiResponse[project.StatusCounts]{ Success: true, Data: project.StatusCounts{ - RunningProjects: int(running), - StoppedProjects: int(stopped), - TotalProjects: int(total), - ArchivedProjects: int(archived), + RunningProjects: running, + StoppedProjects: stopped, + TotalProjects: total, + ArchivedProjects: archived, }, }, }, nil @@ -1199,7 +1201,7 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() httpx.SetJSONStreamHeaders(humaCtx) - runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) //nolint:contextcheck // activity context intentionally outlives the HTTP stream (uses app lifecycle context) + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, @@ -1268,7 +1270,7 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() httpx.SetJSONStreamHeaders(humaCtx) - runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) //nolint:contextcheck // activity context intentionally outlives the HTTP stream (uses app lifecycle context) + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) rawWriter := humaCtx.BodyWriter() activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( runtimeCtx, diff --git a/backend/api/handlers/settings.go b/backend/api/handlers/settings.go index 083a413968..bfe5e76eb1 100644 --- a/backend/api/handlers/settings.go +++ b/backend/api/handlers/settings.go @@ -312,7 +312,6 @@ func (h *SettingsHandler) UpdateSettings(ctx context.Context, input *UpdateSetti } func (h *SettingsHandler) validateSettingsUpdateInput(input settings.Update) error { - // Validate projects directory if provided and changed from current value. // Skip validation when the value matches the current (possibly env-overridden) setting // so that saving unrelated settings doesn't fail due to env-provided directory formats. diff --git a/backend/api/handlers/swarm.go b/backend/api/handlers/swarm.go index 2bcf1ef50e..adbd92f908 100644 --- a/backend/api/handlers/swarm.go +++ b/backend/api/handlers/swarm.go @@ -149,6 +149,7 @@ type GetSwarmNodeAgentDeploymentInput struct { type SwarmNodeAgentDeployment struct { DeploymentSnippet + EnvironmentID string `json:"environmentId"` Agent swarmtypes.NodeAgentStatus `json:"agent"` } @@ -1331,7 +1332,7 @@ func (h *SwarmHandler) RenderStackConfig(ctx context.Context, input *RenderSwarm return &RenderSwarmStackConfigOutput{Body: base.ApiResponse[swarmtypes.StackRenderConfigResponse]{Success: true, Data: *resp}}, nil } -// GetSwarmInfo returns the current swarm cluster metadata for an environment. +// GetSwarmStatus returns the current swarm cluster metadata for an environment. // // It delegates to the swarm service to inspect the local swarm state and maps // service-layer failures to the API's HTTP error model. @@ -1935,7 +1936,7 @@ func buildSwarmQueryParams(search, sort, order string, start, limit int) paginat Sort: strings.TrimSpace(sort), Order: pagination.SortOrder(order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: start, Limit: limit, }, diff --git a/backend/api/handlers/system.go b/backend/api/handlers/system.go index a72ca48767..15a65f21bf 100644 --- a/backend/api/handlers/system.go +++ b/backend/api/handlers/system.go @@ -309,7 +309,7 @@ func (h *SystemHandler) GetDockerInfo(ctx context.Context, input *GetDockerInfoI if !docker.IsDockerContainer() { if cgroupLimits, err := docker.DetectCgroupLimits(); err == nil { if limit := cgroupLimits.MemoryLimit; limit > 0 { - limitInt := int64(limit) + limitInt := limit if memTotal == 0 || limitInt < memTotal { memTotal = limitInt } diff --git a/backend/api/handlers/volumes.go b/backend/api/handlers/volumes.go index e205454aa0..461c49f6a3 100644 --- a/backend/api/handlers/volumes.go +++ b/backend/api/handlers/volumes.go @@ -300,6 +300,8 @@ type UploadAndRestoreOutput struct { } // RegisterVolumes registers volume management routes using Huma. +// +//nolint:maintidx // long but flat Huma route-registration function; complexity is sequential, not branching func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &VolumeHandler{ volumeService: volumeService, @@ -673,7 +675,7 @@ func (h *VolumeHandler) ListVolumes(ctx context.Context, input *ListVolumesInput Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -1123,7 +1125,7 @@ func (h *VolumeHandler) ListBackups(ctx context.Context, input *ListBackupsInput Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/ws/handler.go b/backend/api/ws/handler.go index a15208234e..8731d090c9 100644 --- a/backend/api/ws/handler.go +++ b/backend/api/ws/handler.go @@ -157,6 +157,7 @@ type WebSocketHandler struct { logStreams map[string]*wsLogStream cpuCache struct { sync.RWMutex + value float64 timestamp time.Time } @@ -181,6 +182,7 @@ type WebSocketHandler struct { diskUsagePathCache struct { sync.RWMutex + value string timestamp time.Time } @@ -813,7 +815,7 @@ func (h *WebSocketHandler) ContainerStats(c echo.Context) error { conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) if err != nil { slog.DebugContext(c.Request().Context(), "Failed to upgrade WebSocket for container stats", "containerID", containerID, "error", err) - return nil //nolint:nilerr // Upgrade has already written an HTTP error response to the client. + return nil } connID := h.wsMetrics.RegisterConnection(buildWSConnectionInfoInternal(c, systemtypes.WSKindContainerStats, containerID)) @@ -829,13 +831,20 @@ func (h *WebSocketHandler) ContainerStats(c echo.Context) error { func (h *WebSocketHandler) getOrCreateContainerStatsHubInternal(containerID string) *wshub.Hub { if existing, ok := h.containerStatsHubs.Load(containerID); ok { - return existing.(*wshub.Hub) + if hub, ok := existing.(*wshub.Hub); ok { + return hub + } } hub := wshub.NewHub(64) actual, loaded := h.containerStatsHubs.LoadOrStore(containerID, hub) if loaded { - return actual.(*wshub.Hub) + if existingHub, ok := actual.(*wshub.Hub); ok { + return existingHub + } + // type assertion failure is impossible in practice, but avoid running + // an unregistered hub if it somehow occurs + return hub } h.runContainerStatsHubInternal(containerID, hub) @@ -843,7 +852,7 @@ func (h *WebSocketHandler) getOrCreateContainerStatsHubInternal(containerID stri } func (h *WebSocketHandler) runContainerStatsHubInternal(containerID string, hub *wshub.Hub) { - ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // cancel is intentionally retained and invoked by the hub OnEmpty callback. + ctx, cancel := context.WithCancel(context.Background()) var cleanupTimer *time.Timer var cleanupTimerMu sync.Mutex @@ -972,7 +981,7 @@ func (h *WebSocketHandler) execCleanupFuncInternal(ctx context.Context, execSess return func() { slog.Debug("Cleaning up exec session", "execID", execID, "containerID", containerID, "contextErr", ctx.Err()) // Cleanup must proceed even if parent ctx is canceled. - cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) //nolint:contextcheck + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) defer cleanupCancel() if err := execSession.Close(cleanupCtx); err != nil { //nolint:contextcheck slog.Warn("Failed to clean up exec session", "execID", execID, "error", err) @@ -1037,11 +1046,14 @@ func (h *WebSocketHandler) pipeExecInputInternal(ctx context.Context, cancel con // System WebSocket Endpoints // ============================================================================ -// checkRateLimit checks and applies rate limiting for WebSocket connections. +// checkRateLimitInternal checks and applies rate limiting for WebSocket connections. // Returns the counter and whether the connection should be allowed. -func (h *WebSocketHandler) checkRateLimit(clientIP string) (*int32, bool) { +func (h *WebSocketHandler) checkRateLimitInternal(clientIP string) (*int32, bool) { connCount, _ := h.activeConnections.LoadOrStore(clientIP, new(int32)) - count := connCount.(*int32) + count, ok := connCount.(*int32) + if !ok { + return nil, false + } currentCount := atomic.AddInt32(count, 1) if currentCount > 5 { @@ -1051,8 +1063,8 @@ func (h *WebSocketHandler) checkRateLimit(clientIP string) (*int32, bool) { return count, true } -// releaseRateLimit decrements the connection counter and cleans up if needed. -func (h *WebSocketHandler) releaseRateLimit(clientIP string, count *int32) { +// releaseRateLimitInternal decrements the connection counter and cleans up if needed. +func (h *WebSocketHandler) releaseRateLimitInternal(clientIP string, count *int32) { newCount := atomic.AddInt32(count, -1) if newCount <= 0 { h.activeConnections.Delete(clientIP) @@ -1069,7 +1081,7 @@ func (h *WebSocketHandler) acquireSystemStatsSamplerInternal(ctx context.Context return waitForSystemStatsSamplerReadyInternal(ctx, ready) } - samplerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) //nolint:gosec // cancel is intentionally retained in sampler state and invoked when the last subscriber disconnects. + samplerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) ready := make(chan struct{}) h.systemStatsSampler.cancel = cancel h.systemStatsSampler.ready = ready @@ -1371,14 +1383,14 @@ func (h *WebSocketHandler) getCachedCgroupLimitsInternal() *docker.CgroupLimits func (h *WebSocketHandler) SystemStats(c echo.Context) error { clientIP := c.RealIP() - count, allowed := h.checkRateLimit(clientIP) + count, allowed := h.checkRateLimitInternal(clientIP) if !allowed { return c.JSON(http.StatusTooManyRequests, map[string]any{ "success": false, "error": "Too many concurrent stats connections from this IP", }) } - defer h.releaseRateLimit(clientIP, count) + defer h.releaseRateLimitInternal(clientIP, count) conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) if err != nil { diff --git a/backend/cli/upgrade/upgrade.go b/backend/cli/upgrade/upgrade.go index da99b1bf0a..0cad3aa63e 100644 --- a/backend/cli/upgrade/upgrade.go +++ b/backend/cli/upgrade/upgrade.go @@ -2,6 +2,7 @@ package upgrade import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -170,7 +171,7 @@ func findArcaneContainer(ctx context.Context, dockerClient *client.Client) (cont } } - return container.InspectResponse{}, fmt.Errorf("no running Arcane container found") + return container.InspectResponse{}, errors.New("no running Arcane container found") } func isLegacyServerLabel(labels map[string]string) bool { diff --git a/backend/frontend/frontend.go b/backend/frontend/frontend.go index 97e8295bb1..95184dd13e 100644 --- a/backend/frontend/frontend.go +++ b/backend/frontend/frontend.go @@ -47,7 +47,7 @@ func RegisterFrontend(e *echo.Echo) error { if strings.HasPrefix(path, "/api/") { _ = c.JSON(http.StatusNotFound, map[string]any{ "success": false, - "error": fmt.Sprintf("API endpoint not found: %s", path), + "error": "API endpoint not found: " + path, }) return } diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 9ee6a0b430..62279c4090 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -58,11 +58,13 @@ func Bootstrap(ctx context.Context) error { return fmt.Errorf("failed to initialize database: %w", err) } defer func(ctx context.Context) { - // Use background context for shutdown as appCtx is already canceled - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) //nolint:contextcheck + // appCtx is already canceled here, so derive the shutdown deadline from a + // non-canceled copy of it. + baseCtx := context.WithoutCancel(ctx) + shutdownCtx, shutdownCancel := context.WithTimeout(baseCtx, 10*time.Second) defer shutdownCancel() if err := db.Close(); err != nil { - slog.ErrorContext(shutdownCtx, "Error closing database", "error", err) //nolint:contextcheck + slog.ErrorContext(shutdownCtx, "Error closing database", "error", err) } }(appCtx) @@ -93,7 +95,7 @@ func Bootstrap(ctx context.Context) error { startEdgeTunnelClientIfConfigured(appCtx, cfg, router) - err = runServices(appCtx, cfg, router, tunnelServer, scheduler) + err = runServicesInternal(appCtx, cfg, router, tunnelServer, scheduler) if err != nil { return fmt.Errorf("failed to run services: %w", err) } @@ -294,7 +296,7 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl return fmt.Errorf("failed to create pairing request: %w", err) } - req.Header.Set("X-API-Key", cfg.AgentToken) + req.Header.Set("X-Api-Key", cfg.AgentToken) if cfg.EdgeAgent && strings.TrimSpace(cfg.ManagerApiUrl) != "" { edgeClient, edgeErr := edge.NewManagerHTTPClient(&edge.Config{ @@ -312,7 +314,7 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl httpClient = edgeClient } - resp, err := httpClient.Do(req) //nolint:gosec // intentional request to configured manager pairing endpoint + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("pairing request failed: %w", err) } @@ -341,7 +343,9 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl } } -func runServices(appCtx context.Context, cfg *config.Config, router http.Handler, tunnelServer *edge.TunnelServer, schedulers ...interface{ Run(context.Context) error }) error { +func runServicesInternal(appCtx context.Context, cfg *config.Config, router http.Handler, tunnelServer *edge.TunnelServer, schedulers ...interface { + Run(ctx context.Context) error +}) error { for _, s := range schedulers { scheduler := s go func() { @@ -398,7 +402,7 @@ func runServices(appCtx context.Context, cfg *config.Config, router http.Handler } // Use background context for shutdown as appCtx is already canceled - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) //nolint:contextcheck + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck @@ -425,7 +429,7 @@ func prepareServerTLSInternal(ctx context.Context, cfg *config.Config) (bool, st tlsKeyFile := strings.TrimSpace(cfg.TLSKeyFile) edgeCfg := buildEdgeRuntimeConfigInternal(cfg) if useTLS && (tlsCertFile == "" || tlsKeyFile == "") { - return false, "", "", nil, fmt.Errorf("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE") + return false, "", "", nil, errors.New("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE") } if cfg.AgentMode { diff --git a/backend/internal/bootstrap/edge_bootstrap.go b/backend/internal/bootstrap/edge_bootstrap.go index 018ecea140..0df3cdca52 100644 --- a/backend/internal/bootstrap/edge_bootstrap.go +++ b/backend/internal/bootstrap/edge_bootstrap.go @@ -3,6 +3,7 @@ package bootstrap import ( "context" "encoding/json" + "errors" "fmt" "log/slog" @@ -51,7 +52,7 @@ func registerEdgeTunnelRoutes( eventCallback := func(ctx context.Context, envID string, evt *edge.TunnelEvent) error { if evt == nil { - return fmt.Errorf("event payload is required") + return errors.New("event payload is required") } var metadata models.JSON @@ -118,7 +119,7 @@ func registerEdgeTunnelRoutes( Type: models.EventTypeEnvironmentMTLSEnroll, Severity: edgeMTLSEnrollmentSeverityInternal(reenrolled), Title: "Edge mTLS enrollment", - Description: fmt.Sprintf("Edge agent completed mTLS enrollment from %s", remoteAddr), + Description: "Edge agent completed mTLS enrollment from " + remoteAddr, ResourceType: new("environment"), ResourceID: &envIDCopy, ResourceName: &envNameCopy, diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 9bf7b6fa00..ee9064a725 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -90,7 +90,7 @@ func createAuthValidatorInternal(appServices *Services) middleware.AuthValidator return func(ctx context.Context, c echo.Context) bool { req := c.Request() // Check for API key authentication - if apiKey := req.Header.Get("X-API-Key"); apiKey != "" { + if apiKey := req.Header.Get("X-Api-Key"); apiKey != "" { // User-owned API key if user, err := appServices.ApiKey.ValidateApiKey(ctx, apiKey); err == nil && user != nil { return true diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 9f231fbd1d..fd0fce289c 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -1732,7 +1732,7 @@ type UnknownPermissionError struct { } func (e *UnknownPermissionError) Error() string { - return fmt.Sprintf("Unknown permission: %s", e.Perm) + return "Unknown permission: " + e.Perm } func IsUnknownPermissionError(err error) bool { @@ -1748,7 +1748,7 @@ type RolePermissionEscalationError struct { } func (e *RolePermissionEscalationError) Error() string { - return fmt.Sprintf("cannot grant a permission you do not hold: %s", e.Perm) + return "cannot grant a permission you do not hold: " + e.Perm } func IsRolePermissionEscalationError(err error) bool { @@ -1798,6 +1798,38 @@ func IsErrorFederatedCredentialInvalid(err error) bool { return isErrorTypeInternal[*FederatedCredentialInvalidError](err) } +// DefaultTransportTypeError is returned when http.DefaultTransport is not the +// expected *http.Transport concrete type and therefore cannot be cloned. +type DefaultTransportTypeError struct{} + +func (e *DefaultTransportTypeError) Error() string { + return "http.DefaultTransport is not *http.Transport" +} + +// ManagerCALockTypeError is returned when a cached manager CA lock value is not +// the expected *sync.Mutex. +type ManagerCALockTypeError struct{} + +func (e *ManagerCALockTypeError) Error() string { + return "manager CA lock value is not *sync.Mutex" +} + +// ECRTokenResultTypeError is returned when a deduplicated ECR token refresh +// yields a value that is not the expected *ecrTokenResult. +type ECRTokenResultTypeError struct{} + +func (e *ECRTokenResultTypeError) Error() string { + return "unexpected ECR token result type" +} + +// OidcProviderCacheTypeError is returned when a cached OIDC provider value is +// not the expected *oidc.Provider. +type OidcProviderCacheTypeError struct{} + +func (e *OidcProviderCacheTypeError) Error() string { + return "unexpected provider type from cache" +} + type FederatedCredentialInvalidRequestError struct{} func (e *FederatedCredentialInvalidRequestError) Error() string { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 806e7c7585..6c99c02731 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,6 +28,9 @@ const ( // Fields with `options:"file"` support Docker secrets via the _FILE suffix. // Available options: file, toLower, trimTrailingSlash type Config struct { + // BuildablesConfig contains feature-specific configuration that can be conditionally compiled + BuildablesConfig + AppUrl string `env:"APP_URL" default:"http://localhost:3552"` DatabaseURL string `env:"DATABASE_URL" default:"file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" options:"file"` AllowDowngrade bool `env:"ALLOW_DOWNGRADE" default:"false"` @@ -37,7 +40,7 @@ type Config struct { TLSCertFile string `env:"TLS_CERT_FILE" default:""` TLSKeyFile string `env:"TLS_KEY_FILE" default:""` Environment AppEnvironment `env:"ENVIRONMENT" default:"production"` - JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` //nolint:gosec // configuration field name is part of stable config API + JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` JWTRefreshExpiry time.Duration `env:"JWT_REFRESH_EXPIRY" default:"168h"` EncryptionKey string `env:"ENCRYPTION_KEY" default:"arcane-dev-key-32-characters!!!" options:"file"` AdminStaticAPIKey string `env:"ADMIN_STATIC_API_KEY" default:"" options:"file"` @@ -105,9 +108,6 @@ type Config struct { // Timezone for cron job scheduling. Uses IANA timezone names (e.g., "America/New_York", "Europe/London"). // "Local" uses the system's local timezone, "UTC" for Coordinated Universal Time. Timezone string `env:"TZ" default:"Local"` - - // BuildablesConfig contains feature-specific configuration that can be conditionally compiled - BuildablesConfig } func Load() *Config { @@ -218,7 +218,7 @@ func visitConfigFields(v reflect.Value, fn func(reflect.Value, reflect.StructFie } t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) fieldType := t.Field(i) @@ -332,14 +332,14 @@ func setFieldValueInternal(field reflect.Value, fieldType reflect.StructField, v defaultValue := fieldType.Tag.Get("default") if fallback, fallbackErr := time.ParseDuration(defaultValue); fallbackErr == nil { - slog.Warn("Invalid duration for config field, using tagged default", //nolint:gosec // logging invalid config input for diagnostics is intentional here. + slog.Warn("Invalid duration for config field, using tagged default", "reason", reason, "field", envTag, "value", value, "default", defaultValue) field.SetInt(int64(fallback)) } else { - slog.Warn("Invalid duration for config field and invalid tagged default", //nolint:gosec // logging invalid config input for diagnostics is intentional here. + slog.Warn("Invalid duration for config field and invalid tagged default", "reason", reason, "field", envTag, "value", value, @@ -476,7 +476,7 @@ func (c *Config) MaskSensitive() map[string]any { v := reflect.ValueOf(c).Elem() t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) fieldType := t.Field(i) diff --git a/backend/internal/configschema/schema.go b/backend/internal/configschema/schema.go index 095dced737..6481b361b7 100644 --- a/backend/internal/configschema/schema.go +++ b/backend/internal/configschema/schema.go @@ -3,6 +3,7 @@ package configschema import ( "bytes" "encoding/json" + "errors" "fmt" "go/ast" "go/format" @@ -443,7 +444,7 @@ func resolveSourceRootInternal(sourceRoot string) (string, error) { return "", fmt.Errorf("resolve source root from %q: expected backend/internal/config/config.go", sourceRoot) } - return "", fmt.Errorf("resolve source root: run from the repository root/backend directory or pass --source-root") + return "", errors.New("resolve source root: run from the repository root/backend directory or pass --source-root") } func resolveSourceRootCandidateInternal(candidate string) (string, error) { diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index f44ca17e35..9b133f3a96 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -19,6 +19,8 @@ import ( postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source" + + // Registers the "github" source driver for golang-migrate (remote migration sources). _ "github.com/golang-migrate/migrate/v4/source/github" "github.com/golang-migrate/migrate/v4/source/iofs" "gorm.io/driver/postgres" @@ -532,5 +534,5 @@ func ensureSQLiteDirectory(connString string) error { if dir == "" || dir == "." { return nil } - return os.MkdirAll(dir, 0o755) //nolint:gosec // directory path is intentionally derived from configured SQLite DSN + return os.MkdirAll(dir, 0o755) } diff --git a/backend/internal/middleware/environment_middleware.go b/backend/internal/middleware/environment_middleware.go index 7d668e0ebd..e0a1b6eec4 100644 --- a/backend/internal/middleware/environment_middleware.go +++ b/backend/internal/middleware/environment_middleware.go @@ -351,7 +351,7 @@ func (m *EnvironmentMiddleware) proxyHTTP(c echo.Context, target string, accessT }) } - resp, err := m.httpClient.Do(req) //nolint:gosec // intentional proxy request to resolved remote environment URL + resp, err := m.httpClient.Do(req) if err != nil { return c.JSON(http.StatusBadGateway, map[string]any{ "success": false, diff --git a/backend/internal/middleware/rate_limit_middleware.go b/backend/internal/middleware/rate_limit_middleware.go index 20cba51a69..8da33c45bd 100644 --- a/backend/internal/middleware/rate_limit_middleware.go +++ b/backend/internal/middleware/rate_limit_middleware.go @@ -146,7 +146,7 @@ func PerAgentTokenRateLimit(perMinute int, burst int) echo.MiddlewareFunc { req := c.Request() key := strings.TrimSpace(req.Header.Get("X-Arcane-Agent-Token")) if key == "" { - key = strings.TrimSpace(req.Header.Get("X-API-Key")) + key = strings.TrimSpace(req.Header.Get("X-Api-Key")) } if key == "" { return next(c) diff --git a/backend/internal/models/activity.go b/backend/internal/models/activity.go index 31c8d4e5cb..3aaa272052 100644 --- a/backend/internal/models/activity.go +++ b/backend/internal/models/activity.go @@ -46,6 +46,8 @@ const ( ) type Activity struct { + BaseModel + EnvironmentID string `json:"environmentId" gorm:"column:environment_id;not null;index" sortable:"true"` Type ActivityType `json:"type" gorm:"column:type;not null;index" sortable:"true"` Status ActivityStatus `json:"status" gorm:"column:status;not null;index" sortable:"true"` @@ -63,7 +65,6 @@ type Activity struct { DurationMs *int64 `json:"durationMs,omitempty" gorm:"column:duration_ms" sortable:"true"` Error *string `json:"error,omitempty" gorm:"column:error"` Metadata JSON `json:"metadata,omitempty" gorm:"type:text"` - BaseModel } func (Activity) TableName() string { @@ -71,12 +72,13 @@ func (Activity) TableName() string { } type ActivityMessage struct { + BaseModel + ActivityID string `json:"activityId" gorm:"column:activity_id;not null;index"` Level ActivityMessageLevel `json:"level" gorm:"column:level;not null"` Message string `json:"message" gorm:"column:message;not null"` Payload JSON `json:"payload,omitempty" gorm:"type:text"` Activity *Activity `json:"-" gorm:"foreignKey:ActivityID;constraint:OnDelete:CASCADE"` - BaseModel } func (ActivityMessage) TableName() string { diff --git a/backend/internal/models/api_key.go b/backend/internal/models/api_key.go index 861f968585..6a0c21eb1b 100644 --- a/backend/internal/models/api_key.go +++ b/backend/internal/models/api_key.go @@ -5,6 +5,8 @@ import ( ) type ApiKey struct { + BaseModel + Name string `json:"name" gorm:"column:name;not null" sortable:"true"` Description *string `json:"description,omitempty" gorm:"column:description"` KeyHash string `json:"-" gorm:"column:key_hash;not null"` @@ -14,7 +16,6 @@ type ApiKey struct { EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` ExpiresAt *time.Time `json:"expiresAt,omitempty" gorm:"column:expires_at" sortable:"true"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty" gorm:"column:last_used_at" sortable:"true"` - BaseModel } func (ApiKey) TableName() string { diff --git a/backend/internal/models/auto_update.go b/backend/internal/models/auto_update.go index c77c3cbdb7..78c8e1f86d 100644 --- a/backend/internal/models/auto_update.go +++ b/backend/internal/models/auto_update.go @@ -16,6 +16,8 @@ const ( ) type AutoUpdateRecord struct { + BaseModel + ResourceID string `json:"resourceId"` ResourceType string `json:"resourceType"` ResourceName string `json:"resourceName"` @@ -28,7 +30,6 @@ type AutoUpdateRecord struct { NewImageVersions JSON `json:"newImageVersions,omitempty" gorm:"type:text"` Error *string `json:"error,omitempty"` Details JSON `json:"details,omitempty" gorm:"type:text"` - BaseModel } func (AutoUpdateRecord) TableName() string { diff --git a/backend/internal/models/base.go b/backend/internal/models/base.go index 884d59ff28..8978aae249 100644 --- a/backend/internal/models/base.go +++ b/backend/internal/models/base.go @@ -30,7 +30,7 @@ func (m *BaseModel) BeforeUpdate(_ *gorm.DB) (err error) { return nil } -// nolint:recvcheck +//nolint:recvcheck type JSON map[string]any func (j JSON) Value() (driver.Value, error) { @@ -55,7 +55,7 @@ func (j *JSON) Scan(value any) error { } } -// nolint:recvcheck +//nolint:recvcheck type StringSlice []string func (s StringSlice) Value() (driver.Value, error) { diff --git a/backend/internal/models/container_registry.go b/backend/internal/models/container_registry.go index 07fa228b4f..5495d01822 100644 --- a/backend/internal/models/container_registry.go +++ b/backend/internal/models/container_registry.go @@ -5,6 +5,8 @@ import ( ) type ContainerRegistry struct { + BaseModel + URL string `json:"url" sortable:"true"` Username string `json:"username" sortable:"true"` Token string `json:"token"` @@ -19,7 +21,6 @@ type ContainerRegistry struct { ECRTokenGeneratedAt *time.Time `json:"ecrTokenGeneratedAt"` CreatedAt time.Time `json:"createdAt" sortable:"true"` UpdatedAt time.Time `json:"updatedAt" sortable:"true"` - BaseModel } func (ContainerRegistry) TableName() string { diff --git a/backend/internal/models/environment.go b/backend/internal/models/environment.go index ecd7b388db..76e28451d3 100644 --- a/backend/internal/models/environment.go +++ b/backend/internal/models/environment.go @@ -3,6 +3,8 @@ package models import "time" type Environment struct { + BaseModel + Name string `json:"name" sortable:"true"` ApiUrl string `json:"apiUrl" gorm:"column:api_url" sortable:"true"` Status string `json:"status" sortable:"true"` @@ -14,8 +16,6 @@ type Environment struct { ApiKeyID *string `json:"-" gorm:"column:api_key_id"` ParentEnvironmentID *string `json:"-" gorm:"column:parent_environment_id"` SwarmNodeID *string `json:"-" gorm:"column:swarm_node_id"` - - BaseModel } func (Environment) TableName() string { return "environments" } diff --git a/backend/internal/models/event.go b/backend/internal/models/event.go index 09a0703f23..9d15cc7a32 100644 --- a/backend/internal/models/event.go +++ b/backend/internal/models/event.go @@ -10,7 +10,7 @@ type ( ) const ( - // Event types + // EventTypeContainerStart and the constants below enumerate Arcane event types. EventTypeContainerStart EventType = "container.start" EventTypeContainerStop EventType = "container.stop" EventTypeContainerRestart EventType = "container.restart" @@ -89,7 +89,7 @@ const ( EventTypeWebhookDelete EventType = "webhook.delete" EventTypeWebhookTrigger EventType = "webhook.trigger" - // Event severities + // EventSeverityInfo and the constants below enumerate event severities. EventSeverityInfo EventSeverity = "info" EventSeverityWarning EventSeverity = "warning" EventSeverityError EventSeverity = "error" @@ -97,6 +97,8 @@ const ( ) type Event struct { + BaseModel + Type EventType `json:"type" sortable:"true"` Severity EventSeverity `json:"severity" sortable:"true"` Title string `json:"title" sortable:"true"` @@ -109,7 +111,6 @@ type Event struct { EnvironmentID *string `json:"environmentId,omitempty"` Metadata JSON `json:"metadata,omitempty" gorm:"type:text"` Timestamp time.Time `json:"timestamp" sortable:"true"` - BaseModel } func (Event) TableName() string { diff --git a/backend/internal/models/federated_credential.go b/backend/internal/models/federated_credential.go index ce57dd8dfa..11a6a2a9a4 100644 --- a/backend/internal/models/federated_credential.go +++ b/backend/internal/models/federated_credential.go @@ -8,6 +8,8 @@ const ( ) type FederatedCredential struct { + BaseModel + Name string `json:"name" gorm:"column:name;not null" sortable:"true"` Description *string `json:"description,omitempty" gorm:"column:description"` Enabled bool `json:"enabled" gorm:"column:enabled;not null;default:false;index" sortable:"true"` @@ -25,7 +27,6 @@ type FederatedCredential struct { IdentityUser *User `json:"identityUser,omitempty" gorm:"foreignKey:IdentityUserID;constraint:OnDelete:CASCADE"` Role *Role `json:"role,omitempty" gorm:"foreignKey:RoleID;constraint:OnDelete:RESTRICT"` Environment *Environment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID;constraint:OnDelete:SET NULL"` - BaseModel } func (FederatedCredential) TableName() string { diff --git a/backend/internal/models/federated_token_replay.go b/backend/internal/models/federated_token_replay.go index 497f582f65..cae6c530e3 100644 --- a/backend/internal/models/federated_token_replay.go +++ b/backend/internal/models/federated_token_replay.go @@ -3,10 +3,11 @@ package models import "time" type FederatedTokenReplay struct { + BaseModel + TokenHash string `json:"-" gorm:"column:token_hash;not null;uniqueIndex"` IssuerURL string `json:"issuerUrl" gorm:"column:issuer_url;not null;index"` ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` - BaseModel } func (FederatedTokenReplay) TableName() string { diff --git a/backend/internal/models/git_repository.go b/backend/internal/models/git_repository.go index 13fbc28aa6..94f48ad74a 100644 --- a/backend/internal/models/git_repository.go +++ b/backend/internal/models/git_repository.go @@ -1,6 +1,8 @@ package models type GitRepository struct { + BaseModel + Name string `json:"name" sortable:"true" search:"git,repository,repo,source,version,control,github,gitlab,bitbucket"` URL string `json:"url" sortable:"true" search:"url,git,clone,remote,https,ssh"` AuthType string `json:"authType" sortable:"true" search:"auth,authentication,credentials,token,ssh,http"` // none, http, ssh @@ -10,7 +12,6 @@ type GitRepository struct { SSHHostKeyVerification string `json:"sshHostKeyVerification" gorm:"default:accept_new"` // strict, accept_new, skip Description *string `json:"description,omitempty" sortable:"true"` Enabled bool `json:"enabled" sortable:"true" search:"enabled,active,disabled"` - BaseModel } func (GitRepository) TableName() string { diff --git a/backend/internal/models/gitops_sync.go b/backend/internal/models/gitops_sync.go index 90c97fd07f..b83bffbb35 100644 --- a/backend/internal/models/gitops_sync.go +++ b/backend/internal/models/gitops_sync.go @@ -5,6 +5,8 @@ import ( ) type GitOpsSync struct { + BaseModel + Name string `json:"name" sortable:"true" search:"sync,gitops,automation,deploy,deployment,continuous"` EnvironmentID string `json:"environmentId" sortable:"true"` Environment *Environment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"` @@ -27,7 +29,6 @@ type GitOpsSync struct { LastSyncStatus *string `json:"lastSyncStatus,omitempty" search:"status,success,failed,pending,error"` LastSyncError *string `json:"lastSyncError,omitempty"` LastSyncCommit *string `json:"lastSyncCommit,omitempty" search:"commit,hash,sha,revision"` - BaseModel } func (GitOpsSync) TableName() string { diff --git a/backend/internal/models/image_build.go b/backend/internal/models/image_build.go index a34e0c2ede..3191c8e879 100644 --- a/backend/internal/models/image_build.go +++ b/backend/internal/models/image_build.go @@ -11,6 +11,8 @@ const ( ) type ImageBuild struct { + BaseModel + EnvironmentID string `json:"environmentId" gorm:"column:environment_id;index"` UserID *string `json:"userId,omitempty" gorm:"column:user_id"` Username *string `json:"username,omitempty" gorm:"column:username"` @@ -42,7 +44,6 @@ type ImageBuild struct { OutputTruncated bool `json:"outputTruncated" gorm:"column:output_truncated;default:false"` CompletedAt *time.Time `json:"completedAt,omitempty" gorm:"column:completed_at" sortable:"true"` DurationMs *int64 `json:"durationMs,omitempty" gorm:"column:duration_ms"` - BaseModel } func (ImageBuild) TableName() string { diff --git a/backend/internal/models/image_update.go b/backend/internal/models/image_update.go index da3695345a..0555195cc7 100644 --- a/backend/internal/models/image_update.go +++ b/backend/internal/models/image_update.go @@ -5,6 +5,8 @@ import ( ) type ImageUpdateRecord struct { + BaseModel + ID string `json:"id" gorm:"primaryKey;type:text"` Repository string `json:"repository"` Tag string `json:"tag"` @@ -24,8 +26,6 @@ type ImageUpdateRecord struct { UsedCredential bool `json:"usedCredential,omitempty" gorm:"column:used_credential"` NotificationSent bool `json:"notificationSent" gorm:"column:notification_sent;default:false"` - - BaseModel } func (i *ImageUpdateRecord) TableName() string { diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go index 2f9a6f8878..c74dc8a4a2 100644 --- a/backend/internal/models/notification.go +++ b/backend/internal/models/notification.go @@ -127,7 +127,7 @@ type SignalConfig struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` //nolint:gosec // JSON schema compatibility with external provider config + Password string `json:"password,omitempty"` Token string `json:"token,omitempty"` Source string `json:"source"` Recipients []string `json:"recipients"` @@ -151,7 +151,7 @@ type NtfyConfig struct { Port int `json:"port"` Topic string `json:"topic"` Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` //nolint:gosec // JSON schema compatibility with external provider config + Password string `json:"password,omitempty"` Title string `json:"title,omitempty"` Priority string `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` @@ -187,7 +187,7 @@ type MatrixConfig struct { Port int `json:"port,omitempty"` Rooms string `json:"rooms"` Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` //nolint:gosec // JSON schema compatibility with external provider config + Password string `json:"password,omitempty"` DisableTLSVerification bool `json:"disableTlsVerification"` Events map[NotificationEventType]bool `json:"events,omitempty"` } diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go index 8024452155..60173079f6 100644 --- a/backend/internal/models/project.go +++ b/backend/internal/models/project.go @@ -15,6 +15,8 @@ const ( ) type Project struct { + BaseModel + Name string `json:"name" sortable:"true" gorm:"index:idx_projects_name"` DirName *string `json:"dir_name"` Path string `json:"path" sortable:"true" gorm:"uniqueIndex"` @@ -27,8 +29,6 @@ type Project struct { ImageRefsJSON string `json:"image_refs_json,omitempty" gorm:"column:image_refs_json"` IsArchived bool `json:"is_archived" gorm:"column:is_archived;default:false;index"` ArchivedAt *time.Time `json:"archived_at,omitempty" gorm:"column:archived_at"` - - BaseModel } func (Project) TableName() string { diff --git a/backend/internal/models/rbac.go b/backend/internal/models/rbac.go index 5be9a7161f..950f5ee971 100644 --- a/backend/internal/models/rbac.go +++ b/backend/internal/models/rbac.go @@ -3,11 +3,12 @@ package models // Role is a named permission set. Built-in roles (Admin, Editor, Deployer, // Viewer) are seeded by migration 054 and cannot be edited or deleted. type Role struct { + BaseModel + Name string `json:"name" gorm:"column:name;not null;uniqueIndex" sortable:"true"` Description *string `json:"description,omitempty" gorm:"column:description"` Permissions StringSlice `json:"permissions" gorm:"column:permissions;type:text;not null"` BuiltIn bool `json:"builtIn" gorm:"column:built_in;not null;default:false" sortable:"true"` - BaseModel } func (Role) TableName() string { return "roles" } @@ -19,11 +20,12 @@ func (Role) TableName() string { return "roles" } // Source distinguishes manual assignments (managed by admins via the UI) from // assignments synthesized from OIDC group mappings on every login. type UserRoleAssignment struct { + BaseModel + UserID string `json:"userId" gorm:"column:user_id;not null;index"` RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id;index"` Source string `json:"source" gorm:"column:source;not null;default:'manual'"` - BaseModel } func (UserRoleAssignment) TableName() string { return "user_role_assignments" } @@ -39,10 +41,11 @@ const ( // column on api_keys) so we can index by (api_key_id, permission) for fast // lookups in the auth bridge. type ApiKeyPermission struct { + BaseModel + ApiKeyID string `json:"apiKeyId" gorm:"column:api_key_id;not null;index"` Permission string `json:"permission" gorm:"column:permission;not null"` EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` - BaseModel } func (ApiKeyPermission) TableName() string { return "api_key_permissions" } @@ -57,11 +60,12 @@ func (ApiKeyPermission) TableName() string { return "api_key_permissions" } // read-only via the API — they can only be changed by editing the env var // and restarting. type OidcRoleMapping struct { + BaseModel + ClaimValue string `json:"claimValue" gorm:"column:claim_value;not null;index"` RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` Source string `json:"source" gorm:"column:source;not null;default:'manual'"` - BaseModel } func (OidcRoleMapping) TableName() string { return "oidc_role_mappings" } diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 307c3244bf..8f0ed7d765 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -198,7 +198,7 @@ func buildSettingsFieldCacheInternal() { ordered := make([]settingFieldMeta, 0, rt.NumField()) byKey := make(map[string]settingFieldMeta, rt.NumField()) - for i := 0; i < rt.NumField(); i++ { + for i := range rt.NumField() { field := rt.Field(i) key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") if key == "" { @@ -363,7 +363,7 @@ func (e SettingSensitiveForbiddenError) Is(target error) bool { type OidcConfig struct { ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` //nolint:gosec // OIDC schema compatibility requires this field name + ClientSecret string `json:"clientSecret"` IssuerURL string `json:"issuerUrl"` Scopes string `json:"scopes"` diff --git a/backend/internal/models/template.go b/backend/internal/models/template.go index 512edd1874..16c9d93033 100644 --- a/backend/internal/models/template.go +++ b/backend/internal/models/template.go @@ -2,6 +2,7 @@ package models type TemplateRegistry struct { BaseModel + Name string `json:"name"` URL string `json:"url"` Enabled bool `json:"enabled"` @@ -10,6 +11,7 @@ type TemplateRegistry struct { type ComposeTemplate struct { BaseModel + Name string `json:"name"` Description string `json:"description"` Content string `json:"content" gorm:"type:text"` diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 10f54fea8c..e9e9ea948e 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -5,6 +5,8 @@ import ( ) type User struct { + BaseModel + Username string `json:"username" sortable:"true"` PasswordHash string `json:"-" gorm:"column:password_hash"` DisplayName *string `json:"displayName,omitempty" gorm:"column:display_name" sortable:"true"` @@ -19,7 +21,6 @@ type User struct { OidcAccessToken *string `json:"-" gorm:"type:text"` OidcRefreshToken *string `json:"-" gorm:"type:text"` OidcAccessTokenExpiresAt *time.Time `json:"-"` - BaseModel } func (User) TableName() string { diff --git a/backend/internal/models/user_session.go b/backend/internal/models/user_session.go index 086248baaa..7da80280c7 100644 --- a/backend/internal/models/user_session.go +++ b/backend/internal/models/user_session.go @@ -10,6 +10,7 @@ const ( type UserSession struct { BaseModel + UserID string `json:"userId" gorm:"column:user_id;not null;index"` User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` RefreshTokenHash string `json:"-" gorm:"column:refresh_token_hash;not null;uniqueIndex"` diff --git a/backend/internal/models/volume_backup.go b/backend/internal/models/volume_backup.go index 4643ef465c..5384860ccc 100644 --- a/backend/internal/models/volume_backup.go +++ b/backend/internal/models/volume_backup.go @@ -8,6 +8,7 @@ import ( type VolumeBackup struct { BaseModel + VolumeName string `json:"volumeName" gorm:"column:volume_name;index"` Size int64 `json:"size" gorm:"column:size"` CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` diff --git a/backend/internal/models/vulnerability_ignore.go b/backend/internal/models/vulnerability_ignore.go index 59f43297cd..4c1e530f76 100644 --- a/backend/internal/models/vulnerability_ignore.go +++ b/backend/internal/models/vulnerability_ignore.go @@ -52,8 +52,8 @@ func (v *VulnerabilityIgnore) BeforeCreate(db *gorm.DB) error { return nil } -// VulnerabilityIgnoreCompositeKey generates a composite key for deduplication -// This helps prevent duplicate ignore records for the same vulnerability +// CompositeKey generates a composite key for deduplication. +// This helps prevent duplicate ignore records for the same vulnerability. func (v *VulnerabilityIgnore) CompositeKey() string { return v.EnvironmentID + ":" + v.ImageID + ":" + v.VulnerabilityID + ":" + v.PkgName + ":" + v.InstalledVersion } diff --git a/backend/internal/models/vulnerability_scan.go b/backend/internal/models/vulnerability_scan.go index 2f9bbb3bd2..f14a52cdf0 100644 --- a/backend/internal/models/vulnerability_scan.go +++ b/backend/internal/models/vulnerability_scan.go @@ -6,6 +6,8 @@ import ( // VulnerabilityScanRecord stores vulnerability scan results for images type VulnerabilityScanRecord struct { + BaseModel + // ImageID is the Docker image ID (primary key) ID string `json:"id" gorm:"primaryKey;type:text"` @@ -37,8 +39,6 @@ type VulnerabilityScanRecord struct { // ScannerVersion is the version of the scanner used ScannerVersion string `json:"scannerVersion" gorm:"column:scanner_version"` - - BaseModel } func (v *VulnerabilityScanRecord) TableName() string { diff --git a/backend/internal/models/webhook.go b/backend/internal/models/webhook.go index 5275dde023..fd0ad0d638 100644 --- a/backend/internal/models/webhook.go +++ b/backend/internal/models/webhook.go @@ -20,6 +20,8 @@ const ( ) type Webhook struct { + BaseModel + Name string `json:"name" gorm:"column:name;not null"` TokenHash string `json:"-" gorm:"column:token_hash;not null;uniqueIndex"` TokenPrefix string `json:"tokenPrefix" gorm:"column:token_prefix;not null"` @@ -30,7 +32,6 @@ type Webhook struct { EnvironmentID string `json:"environmentId" gorm:"column:environment_id;not null;default:''"` Enabled bool `json:"enabled" gorm:"column:enabled;not null;default:true"` LastTriggeredAt *time.Time `json:"lastTriggeredAt,omitempty" gorm:"column:last_triggered_at"` - BaseModel } func (Webhook) TableName() string { diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go index 7c00bd567c..2a826bd570 100644 --- a/backend/internal/services/activity_service.go +++ b/backend/internal/services/activity_service.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "maps" + "slices" "strings" "sync" "time" @@ -126,7 +127,7 @@ func (s *ActivityService) releaseCancelInternal(activityID string) { func (s *ActivityService) checkInitInternal() error { if s == nil || s.db == nil { - return fmt.Errorf("activity service not initialized") + return errors.New("activity service not initialized") } return nil } @@ -189,7 +190,7 @@ func (s *ActivityService) UpdateActivity(ctx context.Context, activityID string, } activityID = strings.TrimSpace(activityID) if activityID == "" { - return nil, fmt.Errorf("activity id is required") + return nil, errors.New("activity id is required") } updates := map[string]any{ @@ -221,7 +222,7 @@ func (s *ActivityService) UpdateActivity(ctx context.Context, activityID string, return fmt.Errorf("failed to update activity: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("activity not found") + return errors.New("activity not found") } if err := tx.First(&model, "id = ?", activityID).Error; err != nil { return fmt.Errorf("failed to load updated activity: %w", err) @@ -242,7 +243,7 @@ func (s *ActivityService) AppendMessage(ctx context.Context, activityID string, } activityID = strings.TrimSpace(activityID) if activityID == "" { - return nil, fmt.Errorf("activity id is required") + return nil, errors.New("activity id is required") } messageText := strings.TrimSpace(req.Message) @@ -291,7 +292,7 @@ func (s *ActivityService) AppendMessage(ctx context.Context, activityID string, return fmt.Errorf("failed to update activity latest message: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("activity not found") + return errors.New("activity not found") } if err := tx.First(&updated, "id = ?", activityID).Error; err != nil { return fmt.Errorf("failed to load updated activity: %w", err) @@ -320,7 +321,7 @@ func (s *ActivityService) CompleteActivity(ctx context.Context, activityID strin activityID = strings.TrimSpace(activityID) if activityID == "" { - return nil, fmt.Errorf("activity id is required") + return nil, errors.New("activity id is required") } // The activity is reaching a terminal state; release any cancel registration. @@ -386,7 +387,7 @@ func (s *ActivityService) CancelActivity(ctx context.Context, environmentID, act } activityID = strings.TrimSpace(activityID) if activityID == "" { - return nil, fmt.Errorf("activity id is required") + return nil, errors.New("activity id is required") } environmentID = strings.TrimSpace(environmentID) if environmentID == "" { @@ -411,7 +412,7 @@ func (s *ActivityService) CancelActivity(ctx context.Context, environmentID, act writeCtx := utils.ActivityRuntimeContext(ctx, nil) if _, err := s.AppendMessage(writeCtx, activityID, AppendActivityMessageRequest{ Level: models.ActivityMessageLevelWarning, - Message: fmt.Sprintf("Cancellation requested by %s", requestedBy), + Message: "Cancellation requested by " + requestedBy, }); err != nil { slog.DebugContext(ctx, "failed to append cancellation message", "activityId", activityID, "error", err) } @@ -551,8 +552,8 @@ func (s *ActivityService) GetActivityDetail(ctx context.Context, environmentID, } outMessages := make([]activitytypes.Message, 0, len(messages)) - for i := len(messages) - 1; i >= 0; i-- { - outMessages = append(outMessages, activityMessageToDTOInternal(&messages[i])) + for _, v := range slices.Backward(messages) { + outMessages = append(outMessages, activityMessageToDTOInternal(&v)) } return &activitytypes.Detail{ @@ -769,10 +770,7 @@ func clampProgressPtrInternal(value *int) *int { if value == nil { return nil } - clamped := max(*value, 0) - if clamped > 100 { - clamped = 100 - } + clamped := min(max(*value, 0), 100) return &clamped } diff --git a/backend/internal/services/activity_service_test.go b/backend/internal/services/activity_service_test.go index 8942b0b9c9..6419fbb5b9 100644 --- a/backend/internal/services/activity_service_test.go +++ b/backend/internal/services/activity_service_test.go @@ -77,7 +77,7 @@ func TestActivityServiceLifecycleInternal(t *testing.T) { require.Equal(t, 100, *completed.Progress) list, paginationResp, err := service.ListActivitiesPaginated(ctx, "0", pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Limit: 10}, + Params: pagination.Params{Limit: 10}, }) require.NoError(t, err) require.Len(t, list, 1) diff --git a/backend/internal/services/api_key_service.go b/backend/internal/services/api_key_service.go index c4ad15b800..ca0acdb00f 100644 --- a/backend/internal/services/api_key_service.go +++ b/backend/internal/services/api_key_service.go @@ -408,7 +408,7 @@ func (s *ApiKeyService) CreateEnvironmentApiKey(ctx context.Context, environment if len(environmentID) > 8 { envIDShort = environmentID[:8] } - name := fmt.Sprintf("Environment Bootstrap Key - %s", envIDShort) + name := "Environment Bootstrap Key - " + envIDShort created, err := s.createAPIKeyWithRawKey(ctx, nil, rawKey, apikey.CreateApiKey{ Name: name, Description: new("Auto-generated key for environment pairing"), diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 45ef7078f3..291aa82eea 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -30,9 +30,9 @@ var ( ErrOidcAuthDisabled = errors.New("OIDC authentication is disabled") ) -type TokenPair struct { //nolint:gosec // API response contract intentionally includes token fields +type TokenPair struct { AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` //nolint:gosec // API response contract requires refreshToken field + RefreshToken string `json:"refreshToken"` ExpiresAt time.Time `json:"expiresAt"` } @@ -45,6 +45,7 @@ type AuthSettings struct { type userClaims struct { jwt.RegisteredClaims + SessionID string `json:"sid,omitempty"` UserID string `json:"user_id"` Username string `json:"username"` @@ -57,6 +58,7 @@ type userClaims struct { type refreshClaims struct { jwt.RegisteredClaims + UserID string `json:"user_id"` SessionID string `json:"sid,omitempty"` AppVersion string `json:"app_version,omitempty"` diff --git a/backend/internal/services/build_service.go b/backend/internal/services/build_service.go index 0ffd6f5f5a..ffb1db3847 100644 --- a/backend/internal/services/build_service.go +++ b/backend/internal/services/build_service.go @@ -273,17 +273,17 @@ func (s *BuildService) resolveBuildRequestInternal( return req, func() error { return nil }, nil } - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("resolving remote git context %s", source.RepositoryURL)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "resolving remote git context "+source.RepositoryURL) authConfig, matchedRepository, err := s.resolveGitBuildAuthInternal(ctx, source.RepositoryURL) if err != nil { return imagetypes.BuildRequest{}, func() error { return nil }, err } if matchedRepository { - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("using saved git credentials for %s", source.RepositoryURL)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "using saved git credentials for "+source.RepositoryURL) } if libbuild.RequiresGitRemoteProbe(source.RepositoryURL) { - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("verifying remote git repository %s", source.RepositoryURL)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "verifying remote git repository "+source.RepositoryURL) if err := s.probeGitContextInternal(ctx, source.RepositoryURL, authConfig); err != nil { return imagetypes.BuildRequest{}, func() error { return nil }, fmt.Errorf("failed to verify remote git repository %q: %w", source.RepositoryURL, err) } @@ -310,10 +310,10 @@ func (s *BuildService) resolveBuildRequestInternal( } if !info.IsDir() { _ = s.cleanupGitContextInternal(repoPath) - return imagetypes.BuildRequest{}, func() error { return nil }, fmt.Errorf("resolved git build context is not a directory") + return imagetypes.BuildRequest{}, func() error { return nil }, errors.New("resolved git build context is not a directory") } - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("using remote build context %s", source.Raw)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "using remote build context "+source.Raw) resolvedReq := req resolvedReq.ContextDir = contextDir @@ -395,7 +395,7 @@ func writeBuildProgressStatusInternal(progressWriter io.Writer, serviceName, sta func (s *BuildService) ListImageBuildsByEnvironmentPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]imagetypes.BuildRecord, pagination.Response, error) { if s.db == nil { - return nil, pagination.Response{}, fmt.Errorf("build history not available") + return nil, pagination.Response{}, errors.New("build history not available") } var builds []models.ImageBuild @@ -431,7 +431,7 @@ func (s *BuildService) ListImageBuildsByEnvironmentPaginated(ctx context.Context func (s *BuildService) GetImageBuildByID(ctx context.Context, environmentID, buildID string) (*imagetypes.BuildRecord, error) { if s.db == nil { - return nil, fmt.Errorf("build history not available") + return nil, errors.New("build history not available") } var build models.ImageBuild @@ -525,7 +525,7 @@ func (s *BuildService) completeBuildRecord( return fmt.Errorf("failed to update build record: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("build record not found") + return errors.New("build record not found") } return nil }) diff --git a/backend/internal/services/build_workspace_service.go b/backend/internal/services/build_workspace_service.go index da38564b9a..77a72c1a6a 100644 --- a/backend/internal/services/build_workspace_service.go +++ b/backend/internal/services/build_workspace_service.go @@ -109,7 +109,7 @@ func (s *BuildWorkspaceService) GetFileContent(ctx context.Context, filePath str return nil, "", fmt.Errorf("failed to stat file: %w", err) } if info.IsDir() { - return nil, "", fmt.Errorf("path is a directory") + return nil, "", errors.New("path is a directory") } if maxBytes <= 0 { @@ -153,7 +153,7 @@ func (s *BuildWorkspaceService) DownloadFile(ctx context.Context, filePath strin return nil, 0, fmt.Errorf("failed to stat file: %w", err) } if info.IsDir() { - return nil, 0, fmt.Errorf("path is a directory") + return nil, 0, errors.New("path is a directory") } file, err := os.Open(fullPath) @@ -245,7 +245,7 @@ func (s *BuildWorkspaceService) DeleteFile(ctx context.Context, filePath string) } if cleaned == "/" { - return fmt.Errorf("cannot delete root directory") + return errors.New("cannot delete root directory") } fullPath, err := joinBuildRoot(root, cleaned) @@ -271,7 +271,7 @@ func (s *BuildWorkspaceService) resolveRoot() (string, error) { } if !filepath.IsAbs(root) { - return "", fmt.Errorf("builds directory must be an absolute path") + return "", errors.New("builds directory must be an absolute path") } cleaned := filepath.Clean(root) @@ -293,10 +293,10 @@ func sanitizeBuildPath(input string) (string, error) { cleaned = "/" + cleaned } if strings.Contains(cleaned, "/../") || strings.HasSuffix(cleaned, "/..") || cleaned == "/.." { - return "", fmt.Errorf("invalid path: path traversal not allowed") + return "", errors.New("invalid path: path traversal not allowed") } if !strings.HasPrefix(cleaned, "/") { - return "", fmt.Errorf("invalid path: must be absolute") + return "", errors.New("invalid path: must be absolute") } return cleaned, nil @@ -305,25 +305,25 @@ func sanitizeBuildPath(input string) (string, error) { func sanitizeUploadFilename(filename string) (string, error) { name := strings.TrimSpace(filename) if name == "" { - return "", fmt.Errorf("invalid filename") + return "", errors.New("invalid filename") } // Reject any path separators (handle both Unix and Windows-style separators). if strings.Contains(name, "/") || strings.Contains(name, "\\") { - return "", fmt.Errorf("invalid filename: must not contain path separators") + return "", errors.New("invalid filename: must not contain path separators") } // On Windows, disallow drive/volume prefixes (e.g. C: or \\server\share). if vol := filepath.VolumeName(name); vol != "" { - return "", fmt.Errorf("invalid filename: must not include volume prefix") + return "", errors.New("invalid filename: must not include volume prefix") } base := filepath.Base(name) if base != name { - return "", fmt.Errorf("invalid filename: must not contain path separators") + return "", errors.New("invalid filename: must not contain path separators") } if base == "." || base == ".." { - return "", fmt.Errorf("invalid filename") + return "", errors.New("invalid filename") } return base, nil @@ -333,7 +333,7 @@ func joinBuildRoot(root, cleaned string) (string, error) { rel := strings.TrimPrefix(cleaned, "/") fullPath := filepath.Join(root, filepath.FromSlash(rel)) if !isWithinRoot(root, fullPath) { - return "", fmt.Errorf("invalid path: outside builds directory") + return "", errors.New("invalid path: outside builds directory") } return fullPath, nil } diff --git a/backend/internal/services/container_registry_service.go b/backend/internal/services/container_registry_service.go index d594e886d5..7e8a8c2af6 100644 --- a/backend/internal/services/container_registry_service.go +++ b/backend/internal/services/container_registry_service.go @@ -392,7 +392,7 @@ func (s *ContainerRegistryService) GetAllRegistryAuthConfigs(ctx context.Context Password: token, ServerAddress: serverAddress, } - for _, key := range utilsregistry.RegistryAuthLookupKeys(normalizedHost) { + for _, key := range utilsregistry.LookupKeys(normalizedHost) { authConfigs[key] = authConfig } } @@ -708,7 +708,7 @@ func (s *ContainerRegistryService) GetImageDigest(ctx context.Context, imageRef return result.Digest, nil }) - var staleErr *cache.ErrStale + var staleErr *cache.StaleError if err != nil && !errors.As(err, &staleErr) { return "", err } diff --git a/backend/internal/services/container_service.go b/backend/internal/services/container_service.go index 4c7f582df3..fe1c86c05b 100644 --- a/backend/internal/services/container_service.go +++ b/backend/internal/services/container_service.go @@ -285,7 +285,7 @@ func (s *ContainerService) StartContainer(ctx context.Context, containerID strin err = s.eventService.LogContainerEvent(ctx, models.EventTypeContainerStart, containerID, "name", user.ID, user.Username, "0", metadata) if err != nil { - fmt.Printf("Could not log container start action: %s\n", err) + slog.WarnContext(ctx, "could not log container start action", "error", err) } _, err = dockerClient.ContainerStart(ctx, containerID, client.ContainerStartOptions{}) @@ -767,7 +767,7 @@ func (s *ContainerService) CreateContainer(ctx context.Context, config *containe } if logErr := s.eventService.LogContainerEvent(ctx, models.EventTypeContainerCreate, resp.ID, "name", user.ID, user.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log container stop action: %s\n", logErr) + slog.WarnContext(ctx, "could not log container stop action", "error", logErr) } if _, err := dockerClient.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { diff --git a/backend/internal/services/container_service_test.go b/backend/internal/services/container_service_test.go index 13f39f17c4..73ccfbfccc 100644 --- a/backend/internal/services/container_service_test.go +++ b/backend/internal/services/container_service_test.go @@ -40,7 +40,7 @@ func TestPaginateContainerProjectGroupsKeepsProjectWhole(t *testing.T) { groupedItems, resp := paginateContainerProjectGroupsInternal( pagination.FilterResult[containertypes.Summary]{Items: items, TotalCount: int64(len(items)), TotalAvailable: int64(len(items))}, - pagination.QueryParams{PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}}, + pagination.QueryParams{Params: pagination.Params{Start: 0, Limit: 20}}, ) require.Len(t, groupedItems, 19) diff --git a/backend/internal/services/dashboard_service.go b/backend/internal/services/dashboard_service.go index 81cba36c84..33d15305e6 100644 --- a/backend/internal/services/dashboard_service.go +++ b/backend/internal/services/dashboard_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "sort" "time" @@ -65,7 +66,7 @@ func NewDashboardService( func (s *DashboardService) GetSnapshot(ctx context.Context, options DashboardActionItemsOptions) (*dashboardtypes.Snapshot, error) { if s.dockerService == nil { - return nil, fmt.Errorf("docker service not available") + return nil, errors.New("docker service not available") } dockerSnapshot, err := s.dockerService.GetSnapshot(ctx, localEnvironmentID) diff --git a/backend/internal/services/docker_client_service.go b/backend/internal/services/docker_client_service.go index 6830592b60..d7973288f3 100644 --- a/backend/internal/services/docker_client_service.go +++ b/backend/internal/services/docker_client_service.go @@ -557,7 +557,7 @@ func countImageUsageInternal(images []image.Summary, containers []container.Summ return inuse, unused, total } -func (s *DockerClientService) GetAllNetworks(ctx context.Context) (_ []network.Summary, inuseNetworks int, unusedNetworks int, totalNetworks int, error error) { +func (s *DockerClientService) GetAllNetworks(ctx context.Context) ([]network.Summary, int, int, int, error) { containers, err := s.listContainersInternal(ctx) if err != nil { return nil, 0, 0, 0, err diff --git a/backend/internal/services/ecr_token_service.go b/backend/internal/services/ecr_token_service.go index e37e95f463..8f6c1c0222 100644 --- a/backend/internal/services/ecr_token_service.go +++ b/backend/internal/services/ecr_token_service.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" ) @@ -48,7 +49,10 @@ func (s *ContainerRegistryService) GetOrRefreshECRToken(ctx context.Context, reg if sErr != nil { return "", "", sErr } - r := result.(*ecrTokenResult) + r, ok := result.(*ecrTokenResult) + if !ok { + return "", "", &common.ECRTokenResultTypeError{} + } return r.username, r.password, nil } diff --git a/backend/internal/services/environment_service.go b/backend/internal/services/environment_service.go index f283a41c35..4993fec262 100644 --- a/backend/internal/services/environment_service.go +++ b/backend/internal/services/environment_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "net/http" "strings" "sync" @@ -303,12 +304,12 @@ func (s *EnvironmentService) ListSwarmNodeAgentEnvironments(ctx context.Context, func buildSwarmNodeAgentNameInternal(hostname, nodeID string) string { trimmedHostname := strings.TrimSpace(hostname) if trimmedHostname != "" { - return fmt.Sprintf("Swarm Node Agent - %s", trimmedHostname) + return "Swarm Node Agent - " + trimmedHostname } if len(nodeID) > 12 { nodeID = nodeID[:12] } - return fmt.Sprintf("Swarm Node Agent - %s", nodeID) + return "Swarm Node Agent - " + nodeID } func buildSwarmNodeAgentURLInternal(nodeID string) string { @@ -326,7 +327,7 @@ func (s *EnvironmentService) applySwarmNodeAgentApiKeyInternal( rotate bool, ) (string, error) { if env == nil { - return "", fmt.Errorf("environment is required") + return "", errors.New("environment is required") } if !rotate && env.AccessToken != nil && strings.TrimSpace(*env.AccessToken) != "" { @@ -334,7 +335,7 @@ func (s *EnvironmentService) applySwarmNodeAgentApiKeyInternal( } if s.apiKeyService == nil { - return "", fmt.Errorf("api key service not configured") + return "", errors.New("api key service not configured") } apiKeyDto, err := s.apiKeyService.CreateEnvironmentApiKey(ctx, env.ID, userID) @@ -355,10 +356,10 @@ func (s *EnvironmentService) EnsureSwarmNodeAgentEnvironment( rotate bool, ) (*models.Environment, string, error) { if strings.TrimSpace(parentEnvironmentID) == "" { - return nil, "", fmt.Errorf("parent environment ID is required") + return nil, "", errors.New("parent environment ID is required") } if strings.TrimSpace(nodeID) == "" { - return nil, "", fmt.Errorf("swarm node ID is required") + return nil, "", errors.New("swarm node ID is required") } var env models.Environment @@ -447,7 +448,7 @@ func (s *EnvironmentService) GetEnvironmentByID(ctx context.Context, id string) var environment models.Environment if err := s.db.WithContext(ctx).Where("id = ?", id).First(&environment).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("environment not found") + return nil, errors.New("environment not found") } return nil, fmt.Errorf("failed to get environment: %w", err) } @@ -760,7 +761,7 @@ func (s *EnvironmentService) TestConnection(ctx context.Context, id string, cust } return "offline", fmt.Errorf("failed to create request: %w", err) } - resp, err := s.httpClient.Do(req) //nolint:gosec // intentional request to configured remote environment API URL + resp, err := s.httpClient.Do(req) if err != nil { if customApiUrl == nil { _ = s.updateEnvironmentStatusInternal(ctx, id, string(models.EnvironmentStatusOffline)) @@ -787,7 +788,7 @@ func (s *EnvironmentService) testEdgeConnection(ctx context.Context, id string) if !edge.HasActiveTunnel(id) { if _, ok := edge.RequestTunnelAndWait(ctx, id, edge.DefaultTunnelDemandTTL, edge.DefaultTunnelAcquireTimeout()); !ok { _ = s.updateEnvironmentStatusInternal(ctx, id, string(models.EnvironmentStatusOffline)) - return "offline", fmt.Errorf("edge agent is not connected") + return "offline", errors.New("edge agent is not connected") } } @@ -1075,13 +1076,13 @@ func (s *EnvironmentService) GenerateDeploymentSnippets(ctx context.Context, env " environment:", " - AGENT_MODE=true", " - EDGE_TRANSPORT=poll", - fmt.Sprintf(" - AGENT_TOKEN=%s", apiKey), - fmt.Sprintf(" - MANAGER_API_URL=%s", managerURL), + " - AGENT_TOKEN=" + apiKey, + " - MANAGER_API_URL=" + managerURL, " ports:", " - \"3553:3553\"", " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", - fmt.Sprintf(" - arcane-data:%s", deploymentSnippetsDataPath), + " - arcane-data:" + deploymentSnippetsDataPath, "", "volumes:", " arcane-data:", @@ -1121,11 +1122,11 @@ func (s *EnvironmentService) GenerateEdgeDeploymentSnippets(ctx context.Context, " environment:", " - EDGE_AGENT=true", " - EDGE_TRANSPORT=poll", - fmt.Sprintf(" - AGENT_TOKEN=%s", apiKey), - fmt.Sprintf(" - MANAGER_API_URL=%s", managerURL), + " - AGENT_TOKEN=" + apiKey, + " - MANAGER_API_URL=" + managerURL, " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", - fmt.Sprintf(" - arcane-data:%s", deploymentSnippetsDataPath), + " - arcane-data:" + deploymentSnippetsDataPath, "", "volumes:", " arcane-data:", @@ -1224,12 +1225,12 @@ func buildMTLSDeploymentSnippetInternal(managerURL string, apiKey string, genera " - EDGE_AGENT=true", " - EDGE_TRANSPORT=poll", " - EDGE_MTLS_MODE=required", - fmt.Sprintf(" - EDGE_MTLS_ASSETS_DIR=%s", deploymentSnippetsMTLSPath), - fmt.Sprintf(" - AGENT_TOKEN=%s", apiKey), - fmt.Sprintf(" - MANAGER_API_URL=%s", managerURL), + " - EDGE_MTLS_ASSETS_DIR=" + deploymentSnippetsMTLSPath, + " - AGENT_TOKEN=" + apiKey, + " - MANAGER_API_URL=" + managerURL, " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", - fmt.Sprintf(" - arcane-data:%s", deploymentSnippetsDataPath), + " - arcane-data:" + deploymentSnippetsDataPath, "", "volumes:", " arcane-data:", @@ -1268,7 +1269,7 @@ func (s *EnvironmentService) resolveRemoteEnvironmentTargetInternal(ctx context. } if envID == "0" { - return nil, fmt.Errorf("cannot proxy request to local environment") + return nil, errors.New("cannot proxy request to local environment") } targetURL := strings.TrimRight(environment.ApiUrl, "/") @@ -1316,7 +1317,7 @@ func (s *EnvironmentService) getProxyRequestContextInternal(ctx context.Context) return timeouts.WithTimeout(ctx, settings.ProxyRequestTimeout.AsInt(), timeouts.DefaultProxyRequest) } - return context.WithTimeout(ctx, timeouts.DefaultProxyRequest) //nolint:gosec // helper intentionally returns the cancel func to callers. + return context.WithTimeout(ctx, timeouts.DefaultProxyRequest) } func (s *EnvironmentService) buildRemoteRequestInternal( @@ -1327,13 +1328,11 @@ func (s *EnvironmentService) buildRemoteRequestInternal( headers map[string]string, ) (remenv.Request, error) { if target == nil { - return remenv.Request{}, fmt.Errorf("remote environment target is required") + return remenv.Request{}, errors.New("remote environment target is required") } requestHeaders := make(map[string]string, len(headers)+2) - for key, value := range headers { - requestHeaders[key] = value - } + maps.Copy(requestHeaders, headers) if len(body) > 0 && method != http.MethodGet && requestHeaders["Content-Type"] == "" { requestHeaders["Content-Type"] = "application/json" } @@ -1422,7 +1421,7 @@ func ensureRemoteEnvironmentTunnelAvailableInternal(ctx context.Context, envID s return nil } - return fmt.Errorf("edge agent is not connected") + return errors.New("edge agent is not connected") } func doRemoteEnvironmentTunnelRequestInternal( diff --git a/backend/internal/services/environment_service_test.go b/backend/internal/services/environment_service_test.go index 5a54ad497c..d90b868cd9 100644 --- a/backend/internal/services/environment_service_test.go +++ b/backend/internal/services/environment_service_test.go @@ -736,8 +736,8 @@ func TestEnvironmentService_ListMethods_ExcludeHiddenEnvironments(t *testing.T) }).Error) listedEnvironments, _, err := svc.ListEnvironmentsPaginated(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - Filters: map[string]string{}, + Params: pagination.Params{Start: 0, Limit: 20}, + Filters: map[string]string{}, }) require.NoError(t, err) require.Len(t, listedEnvironments, 2) @@ -834,8 +834,8 @@ func TestEnvironmentService_ListEnvironmentsPaginated_FiltersByRuntimeType(t *te for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { listedEnvironments, _, err := svc.ListEnvironmentsPaginated(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - Filters: map[string]string{"type": tt.typeFilter}, + Params: pagination.Params{Start: 0, Limit: 20}, + Filters: map[string]string{"type": tt.typeFilter}, }) require.NoError(t, err) require.ElementsMatch(t, tt.wantIDs, environmentIDsInternal(listedEnvironments)) diff --git a/backend/internal/services/event_service.go b/backend/internal/services/event_service.go index 7f30f0c70a..e685394b57 100644 --- a/backend/internal/services/event_service.go +++ b/backend/internal/services/event_service.go @@ -165,10 +165,10 @@ func (s *EventService) canForwardEventToManagerHTTP() bool { func (s *EventService) forwardEventToManagerHTTP(ctx context.Context, eventModel *models.Event) error { if eventModel == nil { - return fmt.Errorf("event is required") + return errors.New("event is required") } if s.cfg == nil || strings.TrimSpace(s.cfg.AgentToken) == "" { - return fmt.Errorf("agent token is required for manager event sync") + return errors.New("agent token is required for manager event sync") } managerEventsURL, err := managerEventEndpointURL(s.cfg.GetManagerBaseURL()) @@ -203,9 +203,9 @@ func (s *EventService) forwardEventToManagerHTTP(ctx context.Context, eventModel return fmt.Errorf("failed to create manager event request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", s.cfg.AgentToken) + req.Header.Set("X-Api-Key", s.cfg.AgentToken) - resp, err := s.httpClient.Do(req) //nolint:gosec // managerEventsURL is validated in managerEventEndpointURL before request + resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send event to manager: %w", err) } @@ -225,7 +225,7 @@ func (s *EventService) forwardEventToManagerHTTP(ctx context.Context, eventModel func managerEventEndpointURL(rawBaseURL string) (string, error) { trimmed := strings.TrimSpace(rawBaseURL) if trimmed == "" { - return "", fmt.Errorf("manager API URL is required") + return "", errors.New("manager API URL is required") } baseURL, err := url.Parse(trimmed) @@ -236,7 +236,7 @@ func managerEventEndpointURL(rawBaseURL string) (string, error) { return "", fmt.Errorf("unsupported scheme %q", baseURL.Scheme) } if baseURL.Host == "" { - return "", fmt.Errorf("manager API URL host is required") + return "", errors.New("manager API URL host is required") } baseURL.RawQuery = "" @@ -382,7 +382,7 @@ func (s *EventService) DeleteEvent(ctx context.Context, eventID string) error { return fmt.Errorf("failed to delete event: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("event not found") + return errors.New("event not found") } return nil }) @@ -534,7 +534,7 @@ func (s *EventService) LogErrorEvent(ctx context.Context, eventType models.Event eventMetadata["error"] = err.Error() titleCaser := cases.Title(language.English) - title := fmt.Sprintf("%s error", titleCaser.String(resourceType)) + title := titleCaser.String(resourceType) + " error" if resourceName != "" { title = fmt.Sprintf("%s error: %s", titleCaser.String(resourceType), resourceName) } @@ -670,7 +670,7 @@ func (s *EventService) generateEventTitle(eventType models.EventType, resourceNa if def, ok := eventDefinitions[eventType]; ok { return fmt.Sprintf(def.TitleFormat, resourceName) } - return fmt.Sprintf("Event: %s", string(eventType)) + return "Event: " + string(eventType) } func (s *EventService) generateEventDescription(eventType models.EventType, resourceType, resourceName string) string { diff --git a/backend/internal/services/federated_credential_service.go b/backend/internal/services/federated_credential_service.go index 9c0bce591e..a996ab0a33 100644 --- a/backend/internal/services/federated_credential_service.go +++ b/backend/internal/services/federated_credential_service.go @@ -474,7 +474,7 @@ func (s *FederatedCredentialService) providerForIssuerInternal(ctx context.Conte provider, ok := v.(*oidc.Provider) if !ok || provider == nil { - return nil, fmt.Errorf("federated issuer discovery returned invalid provider") + return nil, errors.New("federated issuer discovery returned invalid provider") } return provider, nil } @@ -612,7 +612,6 @@ func normalizeCreateFederatedCredentialInternal(req federatedtypes.CreateFederat } func applyFederatedCredentialUpdateInternal(existing models.FederatedCredential, req federatedtypes.UpdateFederatedCredential) (models.FederatedCredential, bool, error) { - roleChanged := false if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { diff --git a/backend/internal/services/git_repository_service.go b/backend/internal/services/git_repository_service.go index 651a8c00dd..ec9c17302b 100644 --- a/backend/internal/services/git_repository_service.go +++ b/backend/internal/services/git_repository_service.go @@ -55,7 +55,7 @@ func (s *GitRepositoryService) GetRepositoryByID(ctx context.Context, id string) var repository models.GitRepository if err := s.db.WithContext(ctx).Where("id = ?", id).First(&repository).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("repository not found") + return nil, errors.New("repository not found") } return nil, fmt.Errorf("failed to get repository: %w", err) } @@ -66,7 +66,7 @@ func (s *GitRepositoryService) GetRepositoryByName(ctx context.Context, name str var repository models.GitRepository if err := s.db.WithContext(ctx).Where("name = ?", name).First(&repository).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("repository not found") + return nil, errors.New("repository not found") } return nil, fmt.Errorf("failed to get repository: %w", err) } @@ -268,14 +268,11 @@ func validateGitRepositoryCredentialChangeInternal(repository *models.GitReposit if len(missingFields) == 1 { field := missingFields[0] - return &models.ValidationError{Field: field, Message: fmt.Sprintf("Changing repository URL requires re-supplying or clearing the %s", missingCredentialLabels[0])} + return &models.ValidationError{Field: field, Message: "Changing repository URL requires re-supplying or clearing the " + missingCredentialLabels[0]} } return models.NewValidationError( - fmt.Sprintf( - "Changing repository URL requires re-supplying or clearing all stored credentials: %s", - strings.Join(missingCredentialLabels, " and "), - ), + "Changing repository URL requires re-supplying or clearing all stored credentials: "+strings.Join(missingCredentialLabels, " and "), map[string]any{"fields": missingFields}, ) } diff --git a/backend/internal/services/gitops_sync_service.go b/backend/internal/services/gitops_sync_service.go index 504718f86b..2a715176a7 100644 --- a/backend/internal/services/gitops_sync_service.go +++ b/backend/internal/services/gitops_sync_service.go @@ -73,13 +73,13 @@ type stagedDirectorySync struct { func validateSyncLimits(maxFiles *int, maxTotalSize, maxBinarySize *int64) error { if maxFiles != nil && *maxFiles < 0 { - return fmt.Errorf("maxSyncFiles must be non-negative") + return errors.New("maxSyncFiles must be non-negative") } if maxTotalSize != nil && *maxTotalSize < 0 { - return fmt.Errorf("maxSyncTotalSize must be non-negative") + return errors.New("maxSyncTotalSize must be non-negative") } if maxBinarySize != nil && *maxBinarySize < 0 { - return fmt.Errorf("maxSyncBinarySize must be non-negative") + return errors.New("maxSyncBinarySize must be non-negative") } return nil } @@ -214,7 +214,7 @@ func (s *GitOpsSyncService) GetSyncByID(ctx context.Context, environmentID, id s if err := q.First(&sync).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { slog.WarnContext(ctx, "GitOps sync not found", "syncID", id, "environmentID", environmentID) - return nil, fmt.Errorf("sync not found") + return nil, errors.New("sync not found") } slog.ErrorContext(ctx, "Failed to get GitOps sync", "syncID", id, "environmentID", environmentID, "error", err) return nil, fmt.Errorf("failed to get sync: %w", err) @@ -280,7 +280,7 @@ func (s *GitOpsSyncService) CreateSync(ctx context.Context, environmentID string sync.MaxSyncBinarySize = *req.MaxSyncBinarySize } - if err := s.db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { + if err := s.db.WithContext(ctx).Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { slog.ErrorContext(ctx, "Failed to create GitOps sync in database", "name", req.Name, "repositoryID", req.RepositoryID, "environmentID", environmentID, "error", err) return nil, fmt.Errorf("failed to create sync: %w", err) } @@ -488,8 +488,8 @@ func (s *GitOpsSyncService) prepareSyncSource(ctx context.Context, sync *models. } if !s.repoService.gitClient.FileExists(ctx, repoPath, sync.ComposePath) { - errMsg := fmt.Sprintf("compose file not found: %s", sync.ComposePath) - return &preparedSyncSource{repoPath: repoPath, commitHash: commitHash}, s.failSync(ctx, sync.ID, result, sync, actor, fmt.Sprintf("Compose file not found at %s", sync.ComposePath), errMsg) + errMsg := "compose file not found: " + sync.ComposePath + return &preparedSyncSource{repoPath: repoPath, commitHash: commitHash}, s.failSync(ctx, sync.ID, result, sync, actor, "Compose file not found at "+sync.ComposePath, errMsg) } composeContent, err := s.repoService.gitClient.ReadFile(ctx, repoPath, sync.ComposePath) @@ -521,7 +521,7 @@ func (s *GitOpsSyncService) prepareSyncSource(ctx context.Context, sync *models. func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *models.GitOpsSync, id string, actor models.User, result *gitops.SyncResult, source *preparedSyncSource) (*gitops.SyncResult, error) { slog.InfoContext(ctx, "Using directory sync mode", "syncId", id, "composePath", sync.ComposePath) - _, syncFiles, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) + syncFiles, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) if err != nil { return result, s.failSync(ctx, id, result, sync, actor, "Failed to walk directory", err.Error()) } @@ -578,7 +578,7 @@ func (s *GitOpsSyncService) performSwarmStackSyncInternal(ctx context.Context, s var syncFiles []projects.SyncFile if sync.SyncDirectory { - _, files, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) + files, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) if err != nil { return result, s.failSync(ctx, id, result, sync, actor, "Failed to walk directory", err.Error()) } @@ -789,7 +789,7 @@ func (s *GitOpsSyncService) BrowseFiles(ctx context.Context, environmentID, id s repository := sync.Repository if repository == nil { - return nil, fmt.Errorf("repository not found") + return nil, errors.New("repository not found") } authConfig, err := s.repoService.GetAuthConfig(browseCtx, repository) @@ -1001,8 +1001,8 @@ func marshalSyncedFiles(files []string) *string { } // walkAndParseSyncDirectory walks the repository directory and returns all files with their contents. -// Returns the compose file content, the list of SyncFile entries, and an error if any. -func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync *models.GitOpsSync, repoPath string) (string, []projects.SyncFile, error) { +// Returns the list of SyncFile entries and an error if any; it fails if the compose file is missing. +func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync *models.GitOpsSync, repoPath string) ([]projects.SyncFile, error) { slog.InfoContext(ctx, "Starting directory walk", "syncId", sync.ID, "composePath", sync.ComposePath) // Walk the directory to get all files @@ -1010,7 +1010,7 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync walkResult, err := s.repoService.gitClient.WalkDirectory(ctx, repoPath, sync.ComposePath, maxFiles, maxTotalSize, maxBinarySize) if err != nil { - return "", nil, fmt.Errorf("failed to walk directory: %w", err) + return nil, fmt.Errorf("failed to walk directory: %w", err) } slog.InfoContext(ctx, "Directory walk complete", @@ -1022,7 +1022,7 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync // WalkDirectory roots the walk at filepath.Dir(sync.ComposePath), so the // compose file is always emitted at the top level as filepath.Base(sync.ComposePath). composeFileName := filepath.Base(sync.ComposePath) - var composeContent string + composeFound := false // Convert walked files to SyncFile format syncFiles := make([]projects.SyncFile, len(walkResult.Files)) @@ -1032,15 +1032,15 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync Content: f.Content, } if f.RelativePath == composeFileName { - composeContent = string(f.Content) + composeFound = true } } - if composeContent == "" { - return "", nil, fmt.Errorf("compose file %s not found in walked directory", composeFileName) + if !composeFound { + return nil, fmt.Errorf("compose file %s not found in walked directory", composeFileName) } - return composeContent, syncFiles, nil + return syncFiles, nil } // syncProjectDirectoryInternal runs the new directory-sync path end to end: @@ -1505,10 +1505,7 @@ func (s *GitOpsSyncService) validateDirectorySyncStageInternal(ctx context.Conte return 0, err } - pathMapper, pmErr := s.projectService.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper for directory sync validation, continuing without translation", "error", pmErr) - } + pathMapper := s.projectService.getPathMapper(ctx) autoInjectEnv := s.settingsService.GetBoolSetting(ctx, "autoInjectEnv", false) project, err := projects.LoadComposeProjectLenient( diff --git a/backend/internal/services/gitops_sync_service_test.go b/backend/internal/services/gitops_sync_service_test.go index 9ea51479ba..df65672001 100644 --- a/backend/internal/services/gitops_sync_service_test.go +++ b/backend/internal/services/gitops_sync_service_test.go @@ -232,8 +232,14 @@ func TestGitOpsSyncService_DirectorySync_RealWalkWithNestedConfig(t *testing.T) } require.NoError(t, db.Create(sync).Error) - composeContent, syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) + syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) require.NoError(t, err) + var composeContent string + for _, f := range syncFiles { + if f.RelativePath == "docker-compose.yml" { + composeContent = string(f.Content) + } + } assert.Contains(t, composeContent, "./config/dynamic_config.yml") project, syncedFiles, created, changed, err := svc.syncProjectDirectoryInternal(ctx, sync, syncFiles, models.User{}) @@ -307,7 +313,7 @@ func TestGitOpsSyncService_DirectorySync_OverwritesExistingDirectoryAtFilePath(t } require.NoError(t, db.Create(sync).Error) - _, syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) + syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) require.NoError(t, err) updatedProject, syncedFiles, created, changed, err := svc.syncProjectDirectoryInternal(ctx, sync, syncFiles, models.User{}) diff --git a/backend/internal/services/image_service.go b/backend/internal/services/image_service.go index 1d75128191..2adafacc9b 100644 --- a/backend/internal/services/image_service.go +++ b/backend/internal/services/image_service.go @@ -401,14 +401,14 @@ func (s *ImageService) PruneImages(ctx context.Context, options systemtypes.Prun filterArgs := make(client.Filters) switch options.Mode { case systemtypes.PruneImageModeNone: - return nil, fmt.Errorf("image prune mode none is not allowed") + return nil, errors.New("image prune mode none is not allowed") case systemtypes.PruneImageModeDangling: filterArgs = filterArgs.Add("dangling", "true") case systemtypes.PruneImageModeAll: filterArgs = filterArgs.Add("dangling", "false") case systemtypes.PruneImageModeOlderThan: if strings.TrimSpace(options.Until) == "" { - return nil, fmt.Errorf("image prune mode olderThan requires until") + return nil, errors.New("image prune mode olderThan requires until") } filterArgs = filterArgs.Add("until", options.Until) default: diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index 98f20c54f4..f0ae82abeb 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "maps" @@ -135,7 +136,7 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri startTime := time.Now() activityID := s.startImageUpdateActivityInternal(ctx, imageRef, 1) ctx = s.activityService.Track(ctx, activityID) - s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, fmt.Sprintf("Checking %s", imageRef), 20, "Checking remote digest") + s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, "Checking "+imageRef, 20, "Checking remote digest") parts := s.parseImageReference(imageRef) if parts == nil { @@ -208,7 +209,7 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri func (s *ImageUpdateService) checkDigestUpdateWithSnapshotInternal(ctx context.Context, parts *ImageParts) (*imageupdate.Response, *localImageSnapshot, error) { if s.registryService == nil { - return nil, nil, fmt.Errorf("registry service unavailable") + return nil, nil, errors.New("registry service unavailable") } imageRef := fmt.Sprintf("%s/%s:%s", parts.Registry, parts.Repository, parts.Tag) @@ -395,7 +396,7 @@ func (s *ImageUpdateService) resolveImageRefFromInspect(ctx context.Context, doc } } } - return "", fmt.Errorf("no valid tags or digests") + return "", errors.New("no valid tags or digests") } func (s *ImageUpdateService) resolveImageRefFromContainers(ctx context.Context, dockerClient client.APIClient, imageID string) (string, error) { @@ -534,7 +535,7 @@ func (s *ImageUpdateService) saveUpdateResultWithSnapshotInternal(ctx context.Co parts := s.parseImageReference(imageRef) if parts == nil { - return fmt.Errorf("invalid image reference") + return errors.New("invalid image reference") } imageID, err := s.getImageIDByRef(ctx, imageRef) if err != nil { @@ -595,15 +596,15 @@ func countBatchResultOutcomesInternal(imageRefs []string, results map[string]*im // SUCCESS, and up-to-date images stay INFO. func imageCheckResultMessageInternal(imageRef string, res *imageupdate.Response) (models.ActivityMessageLevel, string) { if res == nil { - return models.ActivityMessageLevelError, fmt.Sprintf("%s: check failed", imageRef) + return models.ActivityMessageLevelError, imageRef + ": check failed" } if err := strings.TrimSpace(res.Error); err != "" { return models.ActivityMessageLevelError, fmt.Sprintf("%s: %s", imageRef, err) } if res.HasUpdate { - return models.ActivityMessageLevelSuccess, fmt.Sprintf("%s — update available", imageRef) + return models.ActivityMessageLevelSuccess, imageRef + " — update available" } - return models.ActivityMessageLevelInfo, fmt.Sprintf("%s — up to date", imageRef) + return models.ActivityMessageLevelInfo, imageRef + " — up to date" } func extractRepoAndTagFromImage(dockerImage image.InspectResponse) (repo, tag string) { @@ -1053,7 +1054,7 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs if err := g.Wait(); err != nil { slog.ErrorContext(ctx, "Batch check error", "error", err) - s.completeImageUpdateActivityInternal(ctx, activityID, false, fmt.Sprintf("Image update check failed: %s", err.Error())) + s.completeImageUpdateActivityInternal(ctx, activityID, false, "Image update check failed: "+err.Error()) return results, err } diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 3e0e946ad8..6adcace018 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "sort" @@ -74,10 +75,10 @@ func (s *JobService) GetJobSchedules(ctx context.Context) jobschedule.Config { func (s *JobService) UpdateJobSchedules(ctx context.Context, updates jobschedule.Update) (jobschedule.Config, error) { if s == nil || s.db == nil || s.settings == nil { - return jobschedule.Config{}, fmt.Errorf("job service not initialized") + return jobschedule.Config{}, errors.New("job service not initialized") } if s.cfg != nil && s.cfg.UIConfigurationDisabled { - return jobschedule.Config{}, fmt.Errorf("job schedule updates are disabled") + return jobschedule.Config{}, errors.New("job schedule updates are disabled") } current := s.GetJobSchedules(ctx) @@ -200,7 +201,7 @@ func jobMetadataAffectedBySettingInternal(jobMeta meta.JobMetadata, changed map[ func (s *JobService) ListJobs(ctx context.Context) (*jobschedule.JobListResponse, error) { if s == nil || s.settings == nil { - return nil, fmt.Errorf("job service not initialized") + return nil, errors.New("job service not initialized") } allMetadata := meta.GetAllJobMetadata() @@ -243,7 +244,7 @@ func (s *JobService) RunJobNowInline(ctx context.Context, jobID string) error { func (s *JobService) getRunnableJobInternal(jobID string) (schedulertypes.Job, error) { if s == nil || s.scheduler == nil { - return nil, fmt.Errorf("job service or scheduler not initialized") + return nil, errors.New("job service or scheduler not initialized") } meta, ok := meta.GetJobMetadata(jobID) diff --git a/backend/internal/services/network_service.go b/backend/internal/services/network_service.go index eb76a9c988..14c85fceed 100644 --- a/backend/internal/services/network_service.go +++ b/backend/internal/services/network_service.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "log/slog" "sort" "strings" @@ -188,7 +189,7 @@ func (s *NetworkService) CreateNetwork(ctx context.Context, name string, options "name": name, } if logErr := s.eventService.LogNetworkEvent(ctx, models.EventTypeNetworkCreate, response.ID, name, user.ID, user.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log network creation action: %s\n", logErr) + slog.WarnContext(ctx, "could not log network creation action", "error", logErr) } warning := "" @@ -229,7 +230,7 @@ func (s *NetworkService) RemoveNetwork(ctx context.Context, id string, user mode "networkId": id, } if logErr := s.eventService.LogNetworkEvent(ctx, models.EventTypeNetworkDelete, id, networkName, user.ID, user.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log network delete action: %s\n", logErr) + slog.WarnContext(ctx, "could not log network delete action", "error", logErr) } return nil @@ -254,7 +255,7 @@ func (s *NetworkService) PruneNetworks(ctx context.Context) (*network.PruneRepor "networksDeleted": len(pruneReport.NetworksDeleted), } if logErr := s.eventService.LogNetworkEvent(ctx, models.EventTypeNetworkDelete, "", "bulk_prune", systemUser.ID, systemUser.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log network prune action: %s\n", logErr) + slog.WarnContext(ctx, "could not log network prune action", "error", logErr) } return &pruneReport, nil diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index e51b808337..5f0f2d2dce 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -128,7 +128,7 @@ func (s *NotificationService) resolveNotificationTargetInternal(ctx context.Cont func (s *NotificationService) resolveNotificationTargetForAccessTokenInternal(ctx context.Context, accessToken string) (NotificationTarget, error) { if s.environmentSvc == nil { - return NotificationTarget{}, fmt.Errorf("environment service not initialized") + return NotificationTarget{}, errors.New("environment service not initialized") } env, err := s.environmentSvc.ResolveEnvironmentByAccessToken(ctx, accessToken) @@ -152,10 +152,10 @@ func (s *NotificationService) resolveNotificationTargetForAccessTokenInternal(ct func (s *NotificationService) dispatchNotificationToManagerInternal(ctx context.Context, payload notificationdto.DispatchRequest) error { if s.config == nil || strings.TrimSpace(s.config.GetManagerBaseURL()) == "" { - return fmt.Errorf("manager API URL is required for notification dispatch") + return errors.New("manager API URL is required for notification dispatch") } if strings.TrimSpace(s.config.AgentToken) == "" { - return fmt.Errorf("agent token is required for notification dispatch") + return errors.New("agent token is required for notification dispatch") } body, err := json.Marshal(payload) @@ -169,9 +169,9 @@ func (s *NotificationService) dispatchNotificationToManagerInternal(ctx context. return fmt.Errorf("failed to create notification dispatch request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", s.config.AgentToken) + req.Header.Set("X-Api-Key", s.config.AgentToken) - resp, err := s.httpClient.Do(req) //nolint:gosec // dispatch URL is derived from validated manager configuration + resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to dispatch notification to manager: %w", err) } @@ -187,7 +187,7 @@ func (s *NotificationService) dispatchNotificationToManagerInternal(ctx context. func (s *NotificationService) DispatchNotification(ctx context.Context, accessToken string, payload notificationdto.DispatchRequest) error { if s.config != nil && s.config.AgentMode { - return fmt.Errorf("notification dispatch is manager-only") + return errors.New("notification dispatch is manager-only") } target, err := s.resolveNotificationTargetForAccessTokenInternal(ctx, accessToken) @@ -198,25 +198,25 @@ func (s *NotificationService) DispatchNotification(ctx context.Context, accessTo switch payload.Kind { case notificationdto.DispatchKindImageUpdate: if payload.ImageUpdate == nil { - return fmt.Errorf("image update payload is required") + return errors.New("image update payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendImageUpdateNotificationForTargetInternal(ctx, target, payload.ImageUpdate.ImageRef, &payload.ImageUpdate.UpdateInfo, models.NotificationEventImageUpdate) case notificationdto.DispatchKindBatchImageUpdate: if payload.BatchImageUpdate == nil { - return fmt.Errorf("batch image update payload is required") + return errors.New("batch image update payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendBatchImageUpdateNotificationForTargetInternal(ctx, target, payload.BatchImageUpdate.Updates) case notificationdto.DispatchKindContainerUpdate: if payload.ContainerUpdate == nil { - return fmt.Errorf("container update payload is required") + return errors.New("container update payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendContainerUpdateNotificationForTargetInternal(ctx, target, payload.ContainerUpdate.ContainerName, payload.ContainerUpdate.ImageRef, payload.ContainerUpdate.OldDigest, payload.ContainerUpdate.NewDigest) case notificationdto.DispatchKindVulnerabilityFound: if payload.VulnerabilityFound == nil { - return fmt.Errorf("vulnerability payload is required") + return errors.New("vulnerability payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendVulnerabilityNotificationForTargetInternal(ctx, target, VulnerabilityNotificationPayload{ @@ -230,13 +230,13 @@ func (s *NotificationService) DispatchNotification(ctx context.Context, accessTo }) case notificationdto.DispatchKindPruneReport: if payload.PruneReport == nil { - return fmt.Errorf("prune report payload is required") + return errors.New("prune report payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendPruneReportNotificationForTargetInternal(ctx, target, &payload.PruneReport.Result) case notificationdto.DispatchKindAutoHeal: if payload.AutoHeal == nil { - return fmt.Errorf("auto-heal payload is required") + return errors.New("auto-heal payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendAutoHealNotificationForTargetInternal(ctx, target, payload.AutoHeal.ContainerName, payload.AutoHeal.ContainerID) @@ -299,7 +299,7 @@ func (s *NotificationService) DeleteSettings(ctx context.Context, provider model func (s *NotificationService) SendImageUpdateNotification(ctx context.Context, imageRef string, updateInfo *imageupdate.Response, eventType models.NotificationEventType) error { if updateInfo == nil { - return fmt.Errorf("updateInfo is required") + return errors.New("updateInfo is required") } if s.config != nil && s.config.AgentMode { @@ -586,7 +586,7 @@ func (s *NotificationService) sendDiscordNotification(ctx context.Context, envir } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } // Decrypt token if encrypted @@ -623,10 +623,10 @@ func (s *NotificationService) sendTelegramNotification(ctx context.Context, envi } if telegramConfig.BotToken == "" { - return fmt.Errorf("telegram bot token not configured") + return errors.New("telegram bot token not configured") } if len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } // Decrypt bot token if encrypted @@ -668,10 +668,10 @@ func (s *NotificationService) sendEmailNotification(ctx context.Context, environ } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -696,7 +696,7 @@ func (s *NotificationService) sendEmailNotification(ctx context.Context, environ return fmt.Errorf("failed to render email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Container Update Available: %s", notifications.SanitizeForEmail(imageRef))) + subject := notifications.BuildEmailSubject(environmentName, "Container Update Available: "+notifications.SanitizeForEmail(imageRef)) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -763,7 +763,7 @@ func (s *NotificationService) sendDiscordContainerUpdateNotification(ctx context } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } // Decrypt token if encrypted @@ -802,10 +802,10 @@ func (s *NotificationService) sendTelegramContainerUpdateNotification(ctx contex } if telegramConfig.BotToken == "" { - return fmt.Errorf("telegram bot token not configured") + return errors.New("telegram bot token not configured") } if len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } // Decrypt bot token if encrypted @@ -849,10 +849,10 @@ func (s *NotificationService) sendEmailContainerUpdateNotification(ctx context.C } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -877,7 +877,7 @@ func (s *NotificationService) sendEmailContainerUpdateNotification(ctx context.C return fmt.Errorf("failed to render email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Container Updated: %s", notifications.SanitizeForEmail(containerName))) + subject := notifications.BuildEmailSubject(environmentName, "Container Updated: "+notifications.SanitizeForEmail(containerName)) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -953,7 +953,7 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI // Test vulnerability notification (all providers) if testType == notificationTestTypeVulnerability { payload := VulnerabilityNotificationPayload{ - CVEID: fmt.Sprintf("Daily Summary - %s", time.Now().UTC().Format("2006-01-02")), + CVEID: "Daily Summary - " + time.Now().UTC().Format("2006-01-02"), Severity: "Critical:1 High:3 Medium:2 Low:1 Unknown:0", ImageName: "5 image(s) scanned, 2 with fixable vulnerabilities", FixedVersion: "7 fixable vulnerability record(s)", @@ -1209,10 +1209,10 @@ func (s *NotificationService) sendTestEmail(ctx context.Context, environmentName } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -1454,10 +1454,10 @@ func (s *NotificationService) sendBatchEmailNotification(ctx context.Context, en } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -1558,26 +1558,26 @@ func (s *NotificationService) sendSignalNotification(ctx context.Context, enviro } if signalConfig.Host == "" { - return fmt.Errorf("signal host not configured") + return errors.New("signal host not configured") } if signalConfig.Port == 0 { - return fmt.Errorf("signal port not configured") + return errors.New("signal port not configured") } if signalConfig.Source == "" { - return fmt.Errorf("signal source phone number not configured") + return errors.New("signal source phone number not configured") } if len(signalConfig.Recipients) == 0 { - return fmt.Errorf("no signal recipients configured") + return errors.New("no signal recipients configured") } // Validate authentication hasBasicAuth := signalConfig.User != "" && signalConfig.Password != "" hasTokenAuth := signalConfig.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } // Decrypt sensitive fields if encrypted @@ -1621,26 +1621,26 @@ func (s *NotificationService) sendSignalContainerUpdateNotification(ctx context. } if signalConfig.Host == "" { - return fmt.Errorf("signal host not configured") + return errors.New("signal host not configured") } if signalConfig.Port == 0 { - return fmt.Errorf("signal port not configured") + return errors.New("signal port not configured") } if signalConfig.Source == "" { - return fmt.Errorf("signal source phone number not configured") + return errors.New("signal source phone number not configured") } if len(signalConfig.Recipients) == 0 { - return fmt.Errorf("no signal recipients configured") + return errors.New("no signal recipients configured") } // Validate authentication hasBasicAuth := signalConfig.User != "" && signalConfig.Password != "" hasTokenAuth := signalConfig.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } // Decrypt sensitive fields if encrypted @@ -1689,10 +1689,10 @@ func (s *NotificationService) sendBatchSignalNotification(ctx context.Context, e hasBasicAuth := signalConfig.User != "" && signalConfig.Password != "" hasTokenAuth := signalConfig.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } // Decrypt sensitive fields if encrypted @@ -1853,10 +1853,10 @@ func (s *NotificationService) sendPushoverNotification(ctx context.Context, envi } if pushoverConfig.Token == "" { - return fmt.Errorf("pushover API token not configured") + return errors.New("pushover API token not configured") } if pushoverConfig.User == "" { - return fmt.Errorf("pushover user key not configured") + return errors.New("pushover user key not configured") } message := notifications.BuildImageUpdateNotificationMessage( @@ -1880,10 +1880,10 @@ func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx contex } if pushoverConfig.Token == "" { - return fmt.Errorf("pushover API token not configured") + return errors.New("pushover API token not configured") } if pushoverConfig.User == "" { - return fmt.Errorf("pushover user key not configured") + return errors.New("pushover user key not configured") } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1932,7 +1932,7 @@ func (s *NotificationService) sendGenericNotification(ctx context.Context, envir } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } message := notifications.BuildImageUpdateNotificationMessage( @@ -1961,7 +1961,7 @@ func (s *NotificationService) sendGenericContainerUpdateNotification(ctx context } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2033,10 +2033,10 @@ func (s *NotificationService) sendEmailVulnerabilityNotification(ctx context.Con return fmt.Errorf("failed to unmarshal email config: %w", err) } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { return fmt.Errorf("invalid from address: %w", err) @@ -2057,7 +2057,7 @@ func (s *NotificationService) sendEmailVulnerabilityNotification(ctx context.Con if err != nil { return fmt.Errorf("failed to render summary email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Daily Vulnerability Summary: %s", notifications.SanitizeForEmail(payload.CVEID))) + subject := notifications.BuildEmailSubject(environmentName, "Daily Vulnerability Summary: "+notifications.SanitizeForEmail(payload.CVEID)) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -2074,7 +2074,7 @@ func (s *NotificationService) sendDiscordVulnerabilityNotification(ctx context.C return fmt.Errorf("failed to unmarshal Discord config: %w", err) } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } if discordConfig.Token != "" { decrypted, err := crypto.Decrypt(discordConfig.Token) @@ -2108,10 +2108,10 @@ func (s *NotificationService) sendTelegramVulnerabilityNotification(ctx context. return fmt.Errorf("failed to unmarshal Telegram config: %w", err) } if telegramConfig.BotToken == "" { - return fmt.Errorf("telegram bot token not configured") + return errors.New("telegram bot token not configured") } if len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } if telegramConfig.BotToken != "" { decrypted, err := crypto.Decrypt(telegramConfig.BotToken) @@ -2148,7 +2148,7 @@ func (s *NotificationService) sendSignalVulnerabilityNotification(ctx context.Co return fmt.Errorf("failed to unmarshal Signal config: %w", err) } if signalConfig.Host == "" || signalConfig.Port == 0 || signalConfig.Source == "" || len(signalConfig.Recipients) == 0 { - return fmt.Errorf("signal not fully configured") + return errors.New("signal not fully configured") } if signalConfig.Password != "" { decrypted, err := crypto.Decrypt(signalConfig.Password) @@ -2209,7 +2209,7 @@ func (s *NotificationService) sendPushoverVulnerabilityNotification(ctx context. return err } if pushoverConfig.Token == "" || pushoverConfig.User == "" { - return fmt.Errorf("pushover token or user not configured") + return errors.New("pushover token or user not configured") } message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if pushoverConfig.Title == "" { @@ -2237,7 +2237,7 @@ func (s *NotificationService) sendGotifyVulnerabilityNotification(ctx context.Co } func (s *NotificationService) sendMatrixVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { return err } @@ -2258,7 +2258,7 @@ func (s *NotificationService) sendGenericVulnerabilityNotification(ctx context.C return fmt.Errorf("failed to unmarshal Generic config: %w", err) } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2287,7 +2287,7 @@ func (s *NotificationService) sendBatchGenericNotification(ctx context.Context, } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } title := "Container Image Updates Available" @@ -2366,7 +2366,7 @@ func (s *NotificationService) sendBatchGotifyNotification(ctx context.Context, e } func (s *NotificationService) sendMatrixNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { return err } @@ -2386,7 +2386,7 @@ func (s *NotificationService) sendMatrixNotification(ctx context.Context, enviro } func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { return err } @@ -2408,7 +2408,7 @@ func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context. } func (s *NotificationService) sendBatchMatrixNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - matrixConfig, err := prepareMatrixConfigInternal(config, "Matrix") + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { return err } @@ -2516,7 +2516,7 @@ func (s *NotificationService) sendDiscordPruneNotification(ctx context.Context, } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } if err := s.decryptDiscordTokenInternal(&discordConfig); err != nil { @@ -2539,7 +2539,7 @@ func (s *NotificationService) sendTelegramPruneNotification(ctx context.Context, } if telegramConfig.BotToken == "" || len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("telegram bot token or chat IDs not configured") + return errors.New("telegram bot token or chat IDs not configured") } if err := s.decryptTelegramTokenInternal(&telegramConfig); err != nil { @@ -2759,7 +2759,7 @@ func (s *NotificationService) sendDiscordAutoHealNotification(ctx context.Contex return err } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } if err := s.decryptDiscordTokenInternal(&discordConfig); err != nil { return err @@ -2794,7 +2794,7 @@ func (s *NotificationService) sendTelegramAutoHealNotification(ctx context.Conte return err } if telegramConfig.BotToken == "" || len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("telegram bot token or chat IDs not configured") + return errors.New("telegram bot token or chat IDs not configured") } if err := s.decryptTelegramTokenInternal(&telegramConfig); err != nil { return err @@ -2909,10 +2909,10 @@ func decodeNotificationConfigInternal[T any](config models.JSON, providerName st func (s *NotificationService) validateEmailConfigInternal(config *models.EmailConfig) error { if config.SMTPHost == "" || config.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(config.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } return nil } @@ -2936,7 +2936,7 @@ func prepareSlackConfigInternal(config models.JSON, providerName string, require return models.SlackConfig{}, err } if requireToken && slackConfig.Token == "" { - return models.SlackConfig{}, fmt.Errorf("slack token not configured") + return models.SlackConfig{}, errors.New("slack token not configured") } if err := decryptStringCredentialInternal(&slackConfig.Token); err != nil { return models.SlackConfig{}, err @@ -2950,7 +2950,7 @@ func prepareNtfyConfigInternal(config models.JSON, providerName string, requireT return models.NtfyConfig{}, err } if requireTopic && ntfyConfig.Topic == "" { - return models.NtfyConfig{}, fmt.Errorf("ntfy topic is required") + return models.NtfyConfig{}, errors.New("ntfy topic is required") } if err := decryptStringCredentialInternal(&ntfyConfig.Password); err != nil { return models.NtfyConfig{}, err @@ -2980,8 +2980,8 @@ func prepareGotifyConfigInternal(config models.JSON, providerName string) (model return gotifyConfig, nil } -func prepareMatrixConfigInternal(config models.JSON, providerName string) (models.MatrixConfig, error) { - matrixConfig, err := decodeNotificationConfigInternal[models.MatrixConfig](config, providerName) +func prepareMatrixConfigInternal(config models.JSON) (models.MatrixConfig, error) { + matrixConfig, err := decodeNotificationConfigInternal[models.MatrixConfig](config, "Matrix") if err != nil { return models.MatrixConfig{}, err } diff --git a/backend/internal/services/oidc_service.go b/backend/internal/services/oidc_service.go index e845353664..d77092784a 100644 --- a/backend/internal/services/oidc_service.go +++ b/backend/internal/services/oidc_service.go @@ -20,6 +20,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/sync/singleflight" + "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -206,10 +207,8 @@ func (s *OidcService) ValidateMobileRedirectURI(ctx context.Context, uri string) if uri == "" { return errors.New("mobile redirect URI is empty") } - for _, allowed := range s.GetMobileRedirectAllowlist(ctx) { - if allowed == uri { - return nil - } + if slices.Contains(s.GetMobileRedirectAllowlist(ctx), uri) { + return nil } return fmt.Errorf("mobile redirect URI %q is not in the configured allowlist", uri) } @@ -328,7 +327,11 @@ func (s *OidcService) getOrDiscoverProviderInternal(ctx context.Context, cfg *mo return nil, err } - return v.(*oidc.Provider), nil + provider, ok := v.(*oidc.Provider) + if !ok { + return nil, &common.OidcProviderCacheTypeError{} + } + return provider, nil } func (s *OidcService) discoverProviderInternal(ctx context.Context, issuer string) (*oidc.Provider, string, error) { @@ -450,7 +453,7 @@ func (s *OidcService) fetchUserInfoClaimsInternal(ctx context.Context, cfg *mode request.Header.Set("Accept", "application/json") client := s.getHttpClientInternal(cfg.SkipTlsVerify) - resp, err := client.Do(request) //nolint:gosec // intentional request to configured OIDC userinfo endpoint + resp, err := client.Do(request) if err != nil { return nil, err } @@ -749,7 +752,7 @@ func (s *OidcService) makeDeviceAuthRequestInternal(ctx context.Context, endpoin req.Header.Set("Accept", "application/json") client := s.getHttpClientInternal(skipTls) - resp, err := client.Do(req) //nolint:gosec // intentional request to configured OIDC device authorization endpoint + resp, err := client.Do(req) if err != nil { slog.Error("makeDeviceAuthRequestInternal: request failed", "error", err) return nil, fmt.Errorf("device authorization request failed: %w", err) @@ -858,7 +861,7 @@ func (s *OidcService) makeTokenRequestInternal(ctx context.Context, endpoint str req.Header.Set("Accept", "application/json") client := s.getHttpClientInternal(skipTls) - resp, err := client.Do(req) //nolint:gosec // intentional request to configured OIDC token endpoint + resp, err := client.Do(req) if err != nil { slog.Error("makeTokenRequestInternal: request failed", "error", err) return nil, fmt.Errorf("token request failed: %w", err) diff --git a/backend/internal/services/playwright_service.go b/backend/internal/services/playwright_service.go index 6552a6c1fa..4a4f13b188 100644 --- a/backend/internal/services/playwright_service.go +++ b/backend/internal/services/playwright_service.go @@ -78,7 +78,7 @@ func (ps *PlaywrightService) DeleteAllTestApiKeys(ctx context.Context) error { SearchQuery: pagination.SearchQuery{ Search: "test-api-key", }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: 0, Limit: 1000, }, diff --git a/backend/internal/services/port_service_test.go b/backend/internal/services/port_service_test.go index 943fab11a3..cff141b6e1 100644 --- a/backend/internal/services/port_service_test.go +++ b/backend/internal/services/port_service_test.go @@ -53,7 +53,7 @@ func TestPortService_ListPortsPaginated_FlattensPublishedAndExposedPorts(t *test })) items, page, err := svc.ListPortsPaginated(context.Background(), pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) @@ -107,7 +107,7 @@ func TestPortService_ListPortsPaginated_SortsByHostPortWithUnpublishedLast(t *te Sort: "hostPort", Order: pagination.SortAsc, }, - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) @@ -154,7 +154,7 @@ func TestPortService_ListPortsPaginated_SortsByHostPortDescWithUnpublishedLast(t Sort: "hostPort", Order: pagination.SortDesc, }, - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) @@ -201,7 +201,7 @@ func TestPortService_ListPortsPaginated_SortsByHostIPDescWithUnpublishedLast(t * Sort: "hostIp", Order: pagination.SortDesc, }, - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index e86f1cdbcf..e11180441d 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -11,6 +11,7 @@ import ( "maps" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -74,7 +75,7 @@ func NewProjectService(db *database.DB, settingsService *SettingsService, eventS } } -func (s *ProjectService) getPathMapper(ctx context.Context) (*projects.PathMapper, error) { +func (s *ProjectService) getPathMapper(ctx context.Context) *projects.PathMapper { configuredPath := s.settingsService.GetStringSetting(ctx, "projectsDirectory", "/app/data/projects") var containerDir, hostDir string @@ -118,10 +119,10 @@ func (s *ProjectService) getPathMapper(ctx context.Context) (*projects.PathMappe pm := projects.NewPathMapper(containerDirResolved, hostDirResolved) if !pm.IsNonMatchingMount() { - return nil, nil + return nil } - return pm, nil + return pm } func (s *ProjectService) getProjectsDirectoryInternal(ctx context.Context) (string, error) { @@ -377,10 +378,10 @@ func (s *ProjectService) GetProjectFromDatabaseByID(ctx context.Context, id stri var project models.Project if err := s.db.WithContext(ctx).Where("id = ?", id).First(&project).Error; err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return nil, fmt.Errorf("request canceled or timed out") + return nil, errors.New("request canceled or timed out") } if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("project not found") + return nil, errors.New("project not found") } return nil, fmt.Errorf("failed to get project: %w", err) } @@ -389,7 +390,7 @@ func (s *ProjectService) GetProjectFromDatabaseByID(ctx context.Context, id stri func (s *ProjectService) GetProjectByComposeName(ctx context.Context, name string) (*models.Project, error) { if name == "" { - return nil, fmt.Errorf("project name is empty") + return nil, errors.New("project name is empty") } normalized := normalizeComposeProjectName(name) @@ -424,7 +425,7 @@ func (s *ProjectService) GetProjectByComposeName(ctx context.Context, name strin func (s *ProjectService) resolveProjectComposeFileInternal(ctx context.Context, proj *models.Project) (string, error) { if proj == nil { - return "", fmt.Errorf("project is nil") + return "", errors.New("project is nil") } if proj.GitOpsManagedBy != nil && strings.TrimSpace(*proj.GitOpsManagedBy) != "" { @@ -472,10 +473,7 @@ func (s *ProjectService) loadComposeProjectForProjectInternal(ctx context.Contex projectsDirectory = "/app/data/projects" } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } + pathMapper := s.getPathMapper(ctx) composeProject, loadErr := projects.LoadComposeProject(ctx, composeFileFullPath, normalizeComposeProjectName(proj.Name), projectsDirectory, utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false), pathMapper) if loadErr != nil { @@ -485,9 +483,9 @@ func (s *ProjectService) loadComposeProjectForProjectInternal(ctx context.Contex return composeProject, composeFileFullPath, nil } -func (s *ProjectService) getCachedComposeProjectInternal(ctx context.Context, proj *models.Project, cfg *models.Settings) (*composetypes.Project, string, error) { +func (s *ProjectService) getCachedComposeProjectInternal(ctx context.Context, proj *models.Project, cfg *models.Settings) (*composetypes.Project, error) { if proj == nil { - return nil, "", fmt.Errorf("project is nil") + return nil, errors.New("project is nil") } if s.composeCache == nil { s.composeCache = cache.NewKeyed[string, composeCacheEntry]() @@ -525,10 +523,10 @@ func (s *ProjectService) getCachedComposeProjectInternal(ctx context.Context, pr return entry, nil }) if err != nil { - return nil, "", err + return nil, err } - return entry.project, entry.composePath, nil + return entry.project, nil } func validComposeCacheEntryInternal(entry composeCacheEntry) bool { @@ -1054,7 +1052,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, err } if strings.TrimSpace(relativePath) == "" { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("relative path is required")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("relative path is required")} } composeFile, detectErr := s.resolveProjectComposeFileInternal(ctx, proj) @@ -1071,7 +1069,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r continue } if !projects.IsSafeSubdirectory(proj.Path, inc.Path) { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } return readProjectIncludeFileContentInternal(proj.Path, inc) } @@ -1088,7 +1086,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, fmt.Errorf("failed to resolve file path: %w", err) } if !projects.IsSafeSubdirectory(absProjectPath, absFilePath) { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } info, err := os.Lstat(absFilePath) @@ -1099,10 +1097,10 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, fmt.Errorf("failed to stat file: %w", err) } if info.IsDir() { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("path refers to a directory")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("path refers to a directory")} } if info.Mode()&os.ModeSymlink != 0 { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("symlink files are not supported")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("symlink files are not supported")} } content, err := os.ReadFile(absFilePath) @@ -1113,7 +1111,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, fmt.Errorf("failed to read file: %w", err) } if projects.IsBinaryProjectFileContent(content) { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("binary files are not supported")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("binary files are not supported")} } return project.IncludeFile{ @@ -1126,7 +1124,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r func readProjectIncludeFileContentInternal(projectPath string, inc projects.IncludeFile) (project.IncludeFile, error) { validatedPath, err := projects.ValidateIncludePathForWrite(projectPath, inc.Path) if err != nil { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } resolvedProjectPath, err := filepath.EvalSymlinks(projectPath) @@ -1145,7 +1143,7 @@ func readProjectIncludeFileContentInternal(projectPath string, inc projects.Incl return project.IncludeFile{}, fmt.Errorf("failed to resolve include file: %w", err) } if !projects.IsSafeSubdirectory(resolvedProjectPath, resolvedPath) { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } info, err := os.Stat(resolvedPath) @@ -1156,7 +1154,7 @@ func readProjectIncludeFileContentInternal(projectPath string, inc projects.Incl return project.IncludeFile{}, fmt.Errorf("failed to stat include file: %w", err) } if info.IsDir() { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("path refers to a directory")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("path refers to a directory")} } content, err := os.ReadFile(resolvedPath) @@ -1167,7 +1165,7 @@ func readProjectIncludeFileContentInternal(projectPath string, inc projects.Incl return project.IncludeFile{}, fmt.Errorf("failed to read include file: %w", err) } if projects.IsBinaryProjectFileContent(content) { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("binary files are not supported")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("binary files are not supported")} } return project.IncludeFile{ @@ -1324,7 +1322,7 @@ func (s *ProjectService) enrichProjectsWithUpdateInfoInternal( } func (s *ProjectService) getProjectImageRefsFromComposeInternal(ctx context.Context, proj models.Project, cfg *models.Settings) ([]string, error) { - composeProject, _, err := s.getCachedComposeProjectInternal(ctx, &proj, cfg) + composeProject, err := s.getCachedComposeProjectInternal(ctx, &proj, cfg) if err != nil { return nil, fmt.Errorf("load compose project: %w", err) } @@ -1429,7 +1427,7 @@ func (s *ProjectService) enrichWithGitOpsInfo(ctx context.Context, proj *models. } func (s *ProjectService) enrichWithComposeServiceConfigs(ctx context.Context, proj *models.Project, composeFile string, resp *project.Details) { - composeProj, _, loadErr := s.getCachedComposeProjectInternal(ctx, proj, nil) + composeProj, loadErr := s.getCachedComposeProjectInternal(ctx, proj, nil) if loadErr != nil { slog.WarnContext(ctx, "failed to load compose service configs", "path", composeFile, "error", loadErr) return @@ -2046,10 +2044,7 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, return err } - disabled, err := s.projectRedeployDisabledInternal(ctx, *proj) - if err != nil { - return err - } + disabled := s.projectRedeployDisabledInternal(ctx, *proj) if disabled { return &common.ArcaneSelfRedeployError{} } @@ -2078,11 +2073,11 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, return s.DeployProject(ctx, projectID, user, nil) } -func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, proj models.Project) (bool, error) { +func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, proj models.Project) bool { containers, err := projects.ListGlobalComposeContainers(ctx) if err != nil { slog.WarnContext(ctx, "could not list compose containers to check self-redeploy guard; skipping guard", "error", err) - return false, nil + return false } containersByProject := make(map[string][]container.Summary) @@ -2096,11 +2091,11 @@ func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, pr currentContainerID, currentContainerErr := dockerutil.GetCurrentContainerID() for _, container := range lookupProjectContainers(proj, containersByProject) { if libupdater.ShouldDisableArcaneServerRedeploy(container.Labels, container.ID, currentContainerID, currentContainerErr) { - return true, nil + return true } } - return false, nil + return false } func (s *ProjectService) PullProjectImages(ctx context.Context, projectID string, progressWriter io.Writer, user models.User, credentials []containerregistry.Credential) error { @@ -2223,11 +2218,6 @@ func (s *ProjectService) prepareProjectImagesForDeploy( return nil } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } - for name, svc := range project.Services { svc, imageName, updated := prepareDeployServiceConfig(projectID, project.Name, name, svc) if updated { @@ -2242,7 +2232,7 @@ func (s *ProjectService) prepareProjectImagesForDeploy( if updated { decision = deployImageDecision{Build: true} } - if err := s.ensureDeployServiceImageReady(ctx, projectID, project, name, svc, imageName, decision, progressWriter, credentials, user, pathMapper); err != nil { + if err := s.ensureDeployServiceImageReady(ctx, projectID, project, name, svc, imageName, decision, progressWriter, credentials, user); err != nil { return err } } @@ -2274,10 +2264,9 @@ func (s *ProjectService) ensureDeployServiceImageReady( progressWriter io.Writer, credentials []containerregistry.Credential, user *models.User, - pathMapper *projects.PathMapper, ) error { if decision.Build { - return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user, pathMapper) + return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user) } exists, err := s.imageService.ImageExistsLocally(ctx, imageName) @@ -2296,14 +2285,15 @@ func (s *ProjectService) ensureDeployServiceImageReady( return nil } - if err := s.pullAndReconcileImageInternal(ctx, imageName, progressWriter, systemUser, credentials); err == nil { + err = s.pullAndReconcileImageInternal(ctx, imageName, progressWriter, systemUser, credentials) + if err == nil { return nil - } else if svc.Build != nil && decision.FallbackBuildOnPullFail { + } + if svc.Build != nil && decision.FallbackBuildOnPullFail { slog.WarnContext(ctx, "image pull failed, falling back to build", "service", serviceName, "image", imageName, "error", err) - return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user, pathMapper) - } else { - return fmt.Errorf("failed to pull image %s: %w", imageName, err) + return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user) } + return fmt.Errorf("failed to pull image %s: %w", imageName, err) } func (s *ProjectService) buildServiceImageForDeploy( @@ -2314,13 +2304,12 @@ func (s *ProjectService) buildServiceImageForDeploy( svc composetypes.ServiceConfig, progressWriter io.Writer, user *models.User, - pathMapper *projects.PathMapper, ) error { if s.buildService == nil { return fmt.Errorf("build service not available for service %s", serviceName) } - buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, serviceName, svc, ProjectBuildOptions{}, pathMapper) + buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, serviceName, svc, ProjectBuildOptions{}) if err != nil { return err } @@ -2443,13 +2432,13 @@ func resolveBuildContextInternal(workingDir string, svc composetypes.ServiceConf return contextDir, nil } -func resolveDockerfilePathInternal(svc composetypes.ServiceConfig) (string, error) { +func resolveDockerfilePathInternal(svc composetypes.ServiceConfig) string { dockerfilePath := strings.TrimSpace(svc.Build.Dockerfile) if dockerfilePath == "" { dockerfilePath = "Dockerfile" } - return dockerfilePath, nil + return dockerfilePath } func buildArgsFromCompose(args map[string]*string) map[string]string { @@ -2504,7 +2493,7 @@ func ulimitsFromCompose(ulimits map[string]*composetypes.UlimitsConfig) map[stri switch { case cfg.Single > 0: - out[name] = fmt.Sprintf("%d", cfg.Single) + out[name] = strconv.Itoa(cfg.Single) case cfg.Soft > 0 || cfg.Hard > 0: out[name] = fmt.Sprintf("%d:%d", cfg.Soft, cfg.Hard) } @@ -2567,7 +2556,6 @@ func (s *ProjectService) prepareServiceBuildRequest( serviceName string, svc composetypes.ServiceConfig, options ProjectBuildOptions, - pathMapper *projects.PathMapper, ) (imagetypes.BuildRequest, composetypes.ServiceConfig, bool, error) { _ = ctx imageName, updatedSvc, updated := ensureServiceImage(projectID, project.Name, serviceName, svc) @@ -2587,7 +2575,7 @@ func (s *ProjectService) prepareServiceBuildRequest( // therefore stay as a container path; translating it to the host path // (which is what bind mount sources need) makes `os.Stat` fail because // the host path doesn't exist inside the Arcane container. See #2314. - // pathMapper is intentionally not consumed here for that reason. + // For that reason the build context dir is deliberately left untranslated. contextDir, err := resolveBuildContextInternal(project.WorkingDir, updatedSvc, serviceName) if err != nil { return imagetypes.BuildRequest{}, updatedSvc, updated, err @@ -2600,10 +2588,7 @@ func (s *ProjectService) prepareServiceBuildRequest( dockerfilePath := "" if strings.TrimSpace(dockerfileInline) == "" { - dockerfilePath, err = resolveDockerfilePathInternal(updatedSvc) - if err != nil { - return imagetypes.BuildRequest{}, updatedSvc, updated, err - } + dockerfilePath = resolveDockerfilePathInternal(updatedSvc) } buildReq := imagetypes.BuildRequest{ @@ -2646,16 +2631,16 @@ func (s *ProjectService) restoreProjectStatusAfterFailedDeployInternal(ctx conte if err == nil { serviceCount, runningCount := s.getServiceCounts(services) status := s.calculateProjectStatus(services) - if updateErr := s.db.WithContext(ctx).Model(&models.Project{}).Where("id = ?", projectID).Updates(map[string]any{ + updateErr := s.db.WithContext(ctx).Model(&models.Project{}).Where("id = ?", projectID).Updates(map[string]any{ "status": status, "service_count": serviceCount, "running_count": runningCount, "updated_at": time.Now(), - }).Error; updateErr == nil { + }).Error + if updateErr == nil { return - } else { - slog.WarnContext(ctx, "failed to restore project status after deploy failure", "projectID", projectID, "error", updateErr) } + slog.WarnContext(ctx, "failed to restore project status after deploy failure", "projectID", projectID, "error", updateErr) } else { slog.WarnContext(ctx, "failed to inspect project services after deploy failure", "projectID", projectID, "error", err) } @@ -2675,11 +2660,6 @@ func (s *ProjectService) buildProjectServicesInternal(ctx context.Context, proje selected := normalizeBuildSelections(options.Services) - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } - buildCount := 0 for name, svc := range project.Services { if svc.Build == nil { @@ -2689,7 +2669,7 @@ func (s *ProjectService) buildProjectServicesInternal(ctx context.Context, proje continue } - buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, name, svc, options, pathMapper) + buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, name, svc, options) if err != nil { return err } @@ -2767,10 +2747,7 @@ func (s *ProjectService) RestartProject(ctx context.Context, projectID string, u projectsDirectory = "/app/data/projects" } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } + pathMapper := s.getPathMapper(ctx) compProj, _, lerr := projects.LoadComposeProjectFromDir(ctx, proj.Path, normalizeComposeProjectName(proj.Name), projectsDirectory, utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false), pathMapper) if lerr != nil { @@ -2908,7 +2885,7 @@ func (s *ProjectService) getProjectForUpdate(ctx context.Context, projectID stri var proj models.Project if err := s.db.WithContext(ctx).First(&proj, "id = ?", projectID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return models.Project{}, "", fmt.Errorf("project not found") + return models.Project{}, "", errors.New("project not found") } return models.Project{}, "", fmt.Errorf("failed to get project: %w", err) } @@ -3164,7 +3141,7 @@ func parseComposeValidationEnvContent(content string, contextEnv projects.EnvMap return nil, fmt.Errorf("parse env: %w", err) } - return projects.EnvMap(envMap), nil + return envMap, nil } func withTransientValidationEnvFile(projectPath string, effectiveEnvContent *string, run func() error) (err error) { @@ -3411,7 +3388,7 @@ func (s *ProjectService) persistGitSyncEnvFilesInternal(projectPath, projectsDir } if update.effectiveContent == nil { - return fmt.Errorf("missing effective env content for git sync update") + return errors.New("missing effective env content for git sync update") } if err := projects.WriteEnvFile(projectsDirectory, projectPath, *update.effectiveContent); err != nil { @@ -3447,7 +3424,7 @@ func (s *ProjectService) applyProjectRenameIfNeeded(proj *models.Project, name * newDirName := projects.SanitizeProjectName(newName) if newDirName == "" || strings.Trim(newDirName, "_") == "" { - return fmt.Errorf("invalid project name: results in empty directory name") + return errors.New("invalid project name: results in empty directory name") } currentPath := filepath.Clean(proj.Path) @@ -3676,7 +3653,6 @@ func (s *ProjectService) listProjectsWithDerivedFiltersInternal( paginationResp := pagination.BuildResponseFromFilterResult(result, params) return result.Items, paginationResp, nil - } func (s *ProjectService) filterProjectsWithDerivedFiltersInternal( @@ -4077,7 +4053,7 @@ func (s *ProjectService) countProjectsByUpdateStatusInternal(ctx context.Context Filters: map[string]string{ "updates": status, }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: 0, Limit: 0, }, @@ -4154,7 +4130,7 @@ func (s *ProjectService) mapProjectToDto(ctx context.Context, projectsDir string projectContainers := lookupProjectContainers(p, containersByProject) - var services []ProjectServiceInfo + services := make([]ProjectServiceInfo, 0, len(projectContainers)) runningCount := 0 for _, c := range projectContainers { @@ -4298,10 +4274,7 @@ func (s *ProjectService) loadComposeMetadataForSyncInternal(ctx context.Context, return 0, nil, pErr } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } + pathMapper := s.getPathMapper(ctx) normName := normalizeComposeProjectName(dirName) autoInjectEnv := utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false) diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index e138e5b616..25b5726f0d 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -2434,8 +2434,8 @@ func TestProjectService_ListProjects_FiltersByUpdateStatus(t *testing.T) { Filters: map[string]string{ "updates": tt.filter, }, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, len(tt.expected), page.TotalItems) @@ -2581,8 +2581,8 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -2590,9 +2590,9 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { assert.Equal(t, "active-demo", items[0].Name) items, page, err = svc.ListProjects(ctx, pagination.QueryParams{ - Filters: map[string]string{"archived": "true"}, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Filters: map[string]string{"archived": "true"}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -2601,9 +2601,9 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { assert.True(t, items[0].IsArchived) items, page, err = svc.ListProjects(ctx, pagination.QueryParams{ - Filters: map[string]string{"archived": "all"}, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Filters: map[string]string{"archived": "all"}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, 2, page.TotalItems) @@ -2800,8 +2800,8 @@ func TestProjectService_ListProjects_WithDerivedStatusFilter_AllowsAllPageSizeSe Filters: map[string]string{ "status": string(models.ProjectStatusStopped), }, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) assert.EqualValues(t, 25, page.TotalItems) @@ -2881,7 +2881,6 @@ func TestProjectService_PrepareServiceBuildRequest_MapsComposeFields(t *testing. "web", serviceCfg, ProjectBuildOptions{}, - nil, ) require.NoError(t, err) @@ -2916,7 +2915,6 @@ func TestProjectService_PrepareServiceBuildRequest_MapsComposeFields(t *testing. func TestProjectService_PrepareServiceBuildRequest_KeepsContainerPaths(t *testing.T) { svc := &ProjectService{} proj := &composetypes.Project{WorkingDir: "/app/data/projects/demo", Name: "demo"} - pm := projects.NewPathMapper("/app/data/projects", "/docker-data/arcane/projects") serviceCfg := composetypes.ServiceConfig{ Name: "web", @@ -2934,7 +2932,6 @@ func TestProjectService_PrepareServiceBuildRequest_KeepsContainerPaths(t *testin "web", serviceCfg, ProjectBuildOptions{}, - pm, ) require.NoError(t, err) @@ -2951,7 +2948,6 @@ func TestProjectService_PrepareServiceBuildRequest_KeepsContainerPaths(t *testin func TestProjectService_PrepareServiceBuildRequest_BuildDotKeepsContainerPath(t *testing.T) { svc := &ProjectService{} proj := &composetypes.Project{WorkingDir: "/app/data/projects/caddy", Name: "caddy"} - pm := projects.NewPathMapper("/app/data/projects", "/storage/volumes/arcane/projects") serviceCfg := composetypes.ServiceConfig{ Name: "caddy", @@ -2968,7 +2964,6 @@ func TestProjectService_PrepareServiceBuildRequest_BuildDotKeepsContainerPath(t "caddy", serviceCfg, ProjectBuildOptions{}, - pm, ) require.NoError(t, err) @@ -2996,7 +2991,6 @@ func TestProjectService_PrepareServiceBuildRequest_UsesInlineDockerfile(t *testi "web", serviceCfg, ProjectBuildOptions{}, - nil, ) require.NoError(t, err) @@ -3064,7 +3058,6 @@ func TestProjectService_PrepareServiceBuildRequest_GeneratedImageProviderGuardra "web", serviceCfg, ProjectBuildOptions{Provider: "depot"}, - nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "must define an image when using depot") @@ -3076,7 +3069,6 @@ func TestProjectService_PrepareServiceBuildRequest_GeneratedImageProviderGuardra "web", serviceCfg, ProjectBuildOptions{Provider: "local", Push: new(true)}, - nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "must define an image when push is enabled") @@ -3281,8 +3273,8 @@ func TestProjectService_SyncProjectsFromFileSystem_DiscoversNestedProjectsAndRel require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, - PaginationParams: pagination.PaginationParams{Limit: -1}, + SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, }) require.NoError(t, err) require.EqualValues(t, 2, page.TotalItems) @@ -3360,8 +3352,8 @@ services: require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, - PaginationParams: pagination.PaginationParams{Limit: -1}, + SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -3527,8 +3519,8 @@ func TestProjectService_SyncProjectsFromFileSystem_DetectsNestedSymlinkedProject require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, - PaginationParams: pagination.PaginationParams{Limit: -1}, + SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) diff --git a/backend/internal/services/role_service.go b/backend/internal/services/role_service.go index 8b9869da90..cc78de3417 100644 --- a/backend/internal/services/role_service.go +++ b/backend/internal/services/role_service.go @@ -302,7 +302,7 @@ func (s *RoleService) GetRole(ctx context.Context, id string) (*models.Role, err func (s *RoleService) CreateRole(ctx context.Context, name string, description *string, permissions []string) (*models.Role, error) { if strings.TrimSpace(name) == "" { - return nil, fmt.Errorf("role name is required") + return nil, errors.New("role name is required") } if err := validatePermissionsInternal(permissions); err != nil { return nil, err @@ -722,10 +722,10 @@ func (s *RoleService) GetOidcMapping(ctx context.Context, id string) (*models.Oi func (s *RoleService) CreateOidcMapping(ctx context.Context, claimValue, roleID string, environmentID *string) (*models.OidcRoleMapping, error) { if strings.TrimSpace(claimValue) == "" { - return nil, fmt.Errorf("claim value is required") + return nil, errors.New("claim value is required") } if strings.TrimSpace(roleID) == "" { - return nil, fmt.Errorf("role id is required") + return nil, errors.New("role id is required") } mapping := &models.OidcRoleMapping{ ClaimValue: claimValue, diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index f879d99164..a31da0bd3b 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -277,14 +277,14 @@ func (s *SettingsService) loadDatabaseConfigFromEnv(ctx context.Context, db *dat slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: env override found", "key", key, "env", envVarName, "valueMasked", mask) rv.Field(i).FieldByName("Value").SetString(utils.TrimQuotes(val)) continue - } else if val, ok := settingsMap[key]; ok { + } + if val, ok := settingsMap[key]; ok { // Fallback to database if environment variable is not set slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: using database fallback", "key", key) rv.Field(i).FieldByName("Value").SetString(val) continue - } else { - slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: env not set and no database value", "key", key, "env", envVarName) } + slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: env not set and no database value", "key", key, "env", envVarName) } // debug: final snapshot (only show which fields are non-empty) @@ -466,7 +466,7 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa changedAutoHeal := false changedTimeouts := make([]libarcane.SettingUpdate, 0) - for i := 0; i < rt.NumField(); i++ { + for i := range rt.NumField() { field := rt.Field(i) fieldValue := rv.Field(i) @@ -499,7 +499,7 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa } if key == "accentColor" && value != "" && value != "default" && !settings.SafeAccentColor.MatchString(value) { - return nil, false, false, false, false, false, nil, fmt.Errorf("invalid accentColor value") + return nil, false, false, false, false, false, nil, errors.New("invalid accentColor value") } var valueToSave string @@ -828,11 +828,11 @@ func (s *SettingsService) GetStringSetting(ctx context.Context, key, defaultValu } func (s *SettingsService) SetBoolSetting(ctx context.Context, key string, value bool) error { - return s.UpdateSetting(ctx, key, fmt.Sprintf("%t", value)) + return s.UpdateSetting(ctx, key, strconv.FormatBool(value)) } func (s *SettingsService) SetIntSetting(ctx context.Context, key string, value int) error { - return s.UpdateSetting(ctx, key, fmt.Sprintf("%d", value)) + return s.UpdateSetting(ctx, key, strconv.Itoa(value)) } func (s *SettingsService) SetStringSetting(ctx context.Context, key, value string) error { diff --git a/backend/internal/services/swarm_service.go b/backend/internal/services/swarm_service.go index 2b148bb421..8e1c683e82 100644 --- a/backend/internal/services/swarm_service.go +++ b/backend/internal/services/swarm_service.go @@ -648,7 +648,7 @@ func (s *SwarmService) fetchSwarmNodeIdentityViaEdgeInternal(ctx context.Context return nil, err } if !parsed.Success { - return nil, fmt.Errorf("swarm node identity probe failed") + return nil, errors.New("swarm node identity probe failed") } return &parsed.Data, nil @@ -841,10 +841,7 @@ func (s *SwarmService) DeployStack(ctx context.Context, environmentID string, re workingDir = stackSourceDir } - pm, err := s.getPathMapperInternal(ctx) - if err != nil { - slog.WarnContext(ctx, "failed to initialize path mapper, deploying without path translation", "error", err) - } + pm := s.getPathMapperInternal(ctx) if err := libswarm.DeployStack(ctx, dockerClient, libswarm.StackDeployOptions{ Name: stackName, @@ -1652,10 +1649,7 @@ func (s *SwarmService) RenderStackConfig(ctx context.Context, req swarmtypes.Sta return nil, err } - pm, err := s.getPathMapperInternal(ctx) - if err != nil { - slog.WarnContext(ctx, "failed to initialize path mapper, deploying without path translation", "error", err) - } + pm := s.getPathMapperInternal(ctx) result, err := libswarm.RenderStackConfig(ctx, libswarm.StackRenderOptions{ Name: req.Name, @@ -2241,7 +2235,7 @@ func (s *SwarmService) resolveSwarmStackSourceEnvironmentDirInternal(ctx context return rootDir, environmentDir, nil } -func (s *SwarmService) getPathMapperInternal(ctx context.Context) (*appfs.PathMapper, error) { +func (s *SwarmService) getPathMapperInternal(ctx context.Context) *appfs.PathMapper { configuredPath := s.settingsService.GetStringSetting(ctx, "swarmStackSourcesDirectory", defaultSwarmStackSourceRootDir) var containerDir, hostDir string @@ -2284,10 +2278,10 @@ func (s *SwarmService) getPathMapperInternal(ctx context.Context) (*appfs.PathMa pm := appfs.NewPathMapper(containerDirResolved, hostDirResolved) if !pm.IsNonMatchingMount() { - return nil, nil + return nil } - return pm, nil + return pm } func (s *SwarmService) ensureSwarmManagerInternal(ctx context.Context) error { @@ -2449,7 +2443,7 @@ func (s *SwarmService) buildStackPaginationConfigInternal() pagination.Config[sw func buildPaginationResponseInternal[T any](result pagination.FilterResult[T], params pagination.QueryParams) pagination.Response { totalPages := int64(0) if params.Limit > 0 { - totalPages = (int64(result.TotalCount) + int64(params.Limit) - 1) / int64(params.Limit) + totalPages = (result.TotalCount + int64(params.Limit) - 1) / int64(params.Limit) } page := 1 @@ -2459,10 +2453,10 @@ func buildPaginationResponseInternal[T any](result pagination.FilterResult[T], p return pagination.Response{ TotalPages: totalPages, - TotalItems: int64(result.TotalCount), + TotalItems: result.TotalCount, CurrentPage: page, ItemsPerPage: params.Limit, - GrandTotalItems: int64(result.TotalAvailable), + GrandTotalItems: result.TotalAvailable, } } diff --git a/backend/internal/services/swarm_service_test.go b/backend/internal/services/swarm_service_test.go index 78cf85ffb1..245bf91660 100644 --- a/backend/internal/services/swarm_service_test.go +++ b/backend/internal/services/swarm_service_test.go @@ -129,8 +129,7 @@ func TestSwarmService_getPathMapperInternal(t *testing.T) { svc := NewSwarmService(nil, settingsSvc, nil, nil, nil) t.Run("returns nil when paths match", func(t *testing.T) { - pm, err := svc.getPathMapperInternal(ctx) - require.NoError(t, err) + pm := svc.getPathMapperInternal(ctx) require.Nil(t, pm) // Default is /app/data/swarm/sources which matches itself }) @@ -140,8 +139,7 @@ func TestSwarmService_getPathMapperInternal(t *testing.T) { err := settingsSvc.UpdateSetting(ctx, "swarmStackSourcesDirectory", containerDir+":"+hostDir) require.NoError(t, err) - pm, err := svc.getPathMapperInternal(ctx) - require.NoError(t, err) + pm := svc.getPathMapperInternal(ctx) require.NotNil(t, pm) require.True(t, pm.IsNonMatchingMount()) diff --git a/backend/internal/services/system_service.go b/backend/internal/services/system_service.go index fbedb9d5bd..bbf0874e56 100644 --- a/backend/internal/services/system_service.go +++ b/backend/internal/services/system_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "regexp" @@ -454,7 +455,7 @@ func (s *SystemService) pruneContainersInternal(ctx context.Context, options sys filterArgs := make(client.Filters) if options.Mode == system.PruneContainerModeOlderThan { if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("container prune mode olderThan requires until") + return errors.New("container prune mode olderThan requires until") } filterArgs = filterArgs.Add("until", options.Until) } @@ -479,14 +480,14 @@ func (s *SystemService) pruneImagesInternal(ctx context.Context, options system. filterArgs := make(client.Filters) switch options.Mode { case system.PruneImageModeNone: - return fmt.Errorf("image prune mode none is not allowed") + return errors.New("image prune mode none is not allowed") case system.PruneImageModeDangling: filterArgs = filterArgs.Add("dangling", "true") case system.PruneImageModeAll: filterArgs = filterArgs.Add("dangling", "false") case system.PruneImageModeOlderThan: if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("image prune mode olderThan requires until") + return errors.New("image prune mode olderThan requires until") } filterArgs = filterArgs.Add("dangling", "false") filterArgs = filterArgs.Add("until", options.Until) @@ -537,7 +538,7 @@ func (s *SystemService) pruneBuildCacheInternal(ctx context.Context, options sys } if options.Mode == system.PruneBuildCacheModeOlderThan { if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("build cache prune mode olderThan requires until") + return errors.New("build cache prune mode olderThan requires until") } pruneOptions.Filters = make(client.Filters) pruneOptions.Filters = pruneOptions.Filters.Add("until", options.Until) @@ -582,7 +583,7 @@ func (s *SystemService) pruneNetworksInternal(ctx context.Context, options syste filterArgs := make(client.Filters) if options.Mode == system.PruneNetworkModeOlderThan { if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("network prune mode olderThan requires until") + return errors.New("network prune mode olderThan requires until") } filterArgs = filterArgs.Add("until", options.Until) } @@ -600,14 +601,14 @@ func (s *SystemService) pruneNetworksInternal(ctx context.Context, options syste func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRunCommand, error) { if command == "" { - return nil, fmt.Errorf("docker run command must be a non-empty string") + return nil, errors.New("docker run command must be a non-empty string") } cmd := strings.TrimSpace(command) cmd = regexp.MustCompile(`^docker\s+run\s+`).ReplaceAllString(cmd, "") if cmd == "" { - return nil, fmt.Errorf("no arguments found after 'docker run'") + return nil, errors.New("no arguments found after 'docker run'") } result := &system.DockerRunCommand{} @@ -617,7 +618,7 @@ func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRun } if len(tokens) == 0 { - return nil, fmt.Errorf("no valid tokens found in docker run command") + return nil, errors.New("no valid tokens found in docker run command") } if err := dockerrun.ParseTokens(tokens, result); err != nil { @@ -625,7 +626,7 @@ func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRun } if result.Image == "" { - return nil, fmt.Errorf("no Docker image specified in command") + return nil, errors.New("no Docker image specified in command") } return result, nil @@ -633,7 +634,7 @@ func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRun func (s *SystemService) ConvertToDockerCompose(parsed *system.DockerRunCommand) (string, string, string, error) { if parsed.Image == "" { - return "", "", "", fmt.Errorf("cannot convert to Docker Compose: no image specified") + return "", "", "", errors.New("cannot convert to Docker Compose: no image specified") } serviceName := parsed.Name diff --git a/backend/internal/services/template_service.go b/backend/internal/services/template_service.go index 39b7f543ca..a60a7b231c 100644 --- a/backend/internal/services/template_service.go +++ b/backend/internal/services/template_service.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "os" "path/filepath" @@ -147,7 +148,7 @@ func (s *TemplateService) ensureRemoteTemplatesLoaded(ctx context.Context) error s.remoteMu.Unlock() }() - if _, err := s.refreshRemoteTemplates(bgCtx); err != nil { + if err := s.refreshRemoteTemplates(bgCtx); err != nil { slog.WarnContext(bgCtx, "background remote template refresh failed", "error", err) } }(ctx) @@ -160,21 +161,20 @@ func (s *TemplateService) ensureRemoteTemplatesLoaded(ctx context.Context) error s.remoteMu.Unlock() // No cache at all, must block - _, err := s.refreshRemoteTemplates(ctx) - return err + return s.refreshRemoteTemplates(ctx) } -func (s *TemplateService) refreshRemoteTemplates(ctx context.Context) ([]models.ComposeTemplate, error) { +func (s *TemplateService) refreshRemoteTemplates(ctx context.Context) error { templates, err := s.loadRemoteTemplates(ctx) if err != nil { - return nil, fmt.Errorf("failed to load remote templates: %w", err) + return fmt.Errorf("failed to load remote templates: %w", err) } s.remoteMu.Lock() defer s.remoteMu.Unlock() s.remoteCache.templates = templates s.remoteCache.lastFetch = time.Now() - return templates, nil + return nil } func (s *TemplateService) GetAllTemplates(ctx context.Context) ([]models.ComposeTemplate, error) { @@ -286,7 +286,7 @@ func (s *TemplateService) GetTemplate(ctx context.Context, id string) (*models.C // silently returned empty. if strings.HasPrefix(id, remoteIDPrefix+":") { slog.InfoContext(ctx, "remote template not in cache, forcing registry refresh", "templateID", id, "cacheSize", s.remoteCacheSizeInternal()) - if _, refreshErr := s.refreshRemoteTemplates(ctx); refreshErr != nil { + if refreshErr := s.refreshRemoteTemplates(ctx); refreshErr != nil { return nil, fmt.Errorf("template %q not found and registry refresh failed: %w", id, refreshErr) } if found := s.lookupRemoteFromCacheInternal(id); found != nil { @@ -344,7 +344,7 @@ func (s *TemplateService) UpdateTemplate(ctx context.Context, id string, updates } if existing.IsRemote { - return fmt.Errorf("cannot update remote template") + return errors.New("cannot update remote template") } existing.Name = updates.Name @@ -372,20 +372,19 @@ func (s *TemplateService) DeleteTemplate(ctx context.Context, id string) error { } if existing.IsRemote { - return fmt.Errorf("cannot delete remote template directly") + return errors.New("cannot delete remote template directly") } baseDir, err := s.getTemplatesDirectoryInternal(ctx) if err != nil { return fmt.Errorf("failed to get templates directory: %w", err) - } else { - templatePath := filepath.Join(baseDir, existing.Name) + } - if stat, err := os.Stat(templatePath); err == nil && stat.IsDir() { - if _, err := projects.DetectComposeFile(templatePath); err == nil { - if err := os.RemoveAll(templatePath); err != nil { - return fmt.Errorf("failed to delete template directory: %w", err) - } + templatePath := filepath.Join(baseDir, existing.Name) + if stat, err := os.Stat(templatePath); err == nil && stat.IsDir() { + if _, err := projects.DetectComposeFile(templatePath); err == nil { + if err := os.RemoveAll(templatePath); err != nil { + return fmt.Errorf("failed to delete template directory: %w", err) } } } @@ -464,9 +463,7 @@ func (s *TemplateService) GetRegistryFetchErrors() map[string]string { s.registryMu.RLock() defer s.registryMu.RUnlock() out := make(map[string]string, len(s.registryErrors)) - for k, v := range s.registryErrors { - out[k] = v - } + maps.Copy(out, s.registryErrors) return out } @@ -474,7 +471,7 @@ func (s *TemplateService) CreateRegistry(ctx context.Context, registry *models.T // Hydrate metadata if needed if registry.Name == "" || registry.Description == "" { if registry.URL == "" { - return fmt.Errorf("registry URL is required") + return errors.New("registry URL is required") } if manifest, err := s.fetchRegistryManifest(ctx, registry.URL); err == nil { if registry.Name == "" { @@ -637,7 +634,7 @@ func (s *TemplateService) doGET(ctx context.Context, url string) ([]byte, error) if err != nil { return nil, err } - resp, err := client.Do(req) //nolint:gosec // intentional request to configured template registry URL + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch %s: %w", url, err) } @@ -669,7 +666,7 @@ func (s *TemplateService) fetchRegistryTemplates(ctx context.Context, reg *model req.Header.Set("If-Modified-Since", fetchMeta.LastModified) } - resp, err := client.Do(req) //nolint:gosec // intentional request to configured template registry URL + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -679,7 +676,7 @@ func (s *TemplateService) fetchRegistryTemplates(ctx context.Context, reg *model if fetchMeta != nil { return cloneRemoteTemplates(fetchMeta.Templates), nil } - return nil, fmt.Errorf("received 304 without cached data") + return nil, errors.New("received 304 without cached data") } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) @@ -723,7 +720,7 @@ func (s *TemplateService) fetchRegistryManifest(ctx context.Context, url string) return nil, fmt.Errorf("failed to parse registry JSON: %w", err) } if reg.Name == "" || len(reg.Templates) == 0 { - return nil, fmt.Errorf("invalid registry manifest: missing required fields (name, templates)") + return nil, errors.New("invalid registry manifest: missing required fields (name, templates)") } return ®, nil } @@ -769,7 +766,7 @@ func (s *TemplateService) FetchTemplateContent(ctx context.Context, template *mo func (s *TemplateService) fetchRemoteTemplateFiles(ctx context.Context, template *models.ComposeTemplate) (string, string, error) { if template == nil || template.Metadata == nil || template.Metadata.RemoteURL == nil { - return "", "", fmt.Errorf("not a remote template or missing remote URL") + return "", "", errors.New("not a remote template or missing remote URL") } composeContent, err := s.fetchURL(ctx, *template.Metadata.RemoteURL) @@ -842,7 +839,7 @@ func (s *TemplateService) newSafeRequestInternal(ctx context.Context, method, ra if client == nil { client = s.newSafeHTTPClientInternal() if client == nil { - return nil, nil, fmt.Errorf("failed to configure safe HTTP client") + return nil, nil, errors.New("failed to configure safe HTTP client") } s.safeHTTPClient = client } @@ -857,7 +854,7 @@ func (s *TemplateService) newSafeRequestInternal(ctx context.Context, method, ra func (s *TemplateService) DownloadTemplate(ctx context.Context, remoteTemplate *models.ComposeTemplate) (*models.ComposeTemplate, error) { if !remoteTemplate.IsRemote { - return nil, fmt.Errorf("template is not remote") + return nil, errors.New("template is not remote") } base := s.templateBaseFromRemote(remoteTemplate) diff --git a/backend/internal/services/template_service_test.go b/backend/internal/services/template_service_test.go index dff9e55898..30d8835e9c 100644 --- a/backend/internal/services/template_service_test.go +++ b/backend/internal/services/template_service_test.go @@ -355,8 +355,8 @@ func TestGetAllTemplatesPaginated_FiltersByType(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { templates, _, err := service.GetAllTemplatesPaginated(context.Background(), pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - Filters: map[string]string{"type": tt.typeFilter}, + Params: pagination.Params{Start: 0, Limit: 20}, + Filters: map[string]string{"type": tt.typeFilter}, }) require.NoError(t, err) require.ElementsMatch(t, tt.wantIDs, templateIDsInternal(templates)) diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index 3940f35199..87e0b202e6 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -90,7 +90,7 @@ func NewUpdaterService( // per-resource result summary. When dryRun is true, it reports the planned // actions without mutating containers or projects. // -//nolint:gocognit +//nolint:gocognit,gocyclo,maintidx // large but sequential update-orchestration flow; refactor tracked separately func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (out *updater.Result, err error) { start := time.Now() out = &updater.Result{Items: []updater.ResourceResult{}} @@ -191,7 +191,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (out *up for i := range plans { p := plans[i] - s.appendAutoUpdateActivityMessageInternal(ctx, activityID, fmt.Sprintf("Checking update for %s", p.oldRef), "Checking image", 20) + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Checking update for "+p.oldRef, "Checking image", 20) item := updater.ResourceResult{ ResourceID: p.oldRef, ResourceType: "image", @@ -501,7 +501,7 @@ func (s *UpdaterService) completeAutoUpdateActivityInternal(ctx context.Context, // UpdateSingleContainer updates a single container by ID to the latest available image. // It pulls the new image, stops the container, removes it, and recreates it with the new image. // -//nolint:gocognit // single-container update flow is intentionally linear with explicit early exits for failure reporting +//nolint:gocognit,gocyclo,maintidx // single-container update flow is intentionally linear with explicit early exits for failure reporting func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID string) (out *updater.Result, err error) { start := time.Now() out = &updater.Result{Items: []updater.ResourceResult{}} @@ -876,7 +876,6 @@ func (s *UpdaterService) GetHistory(ctx context.Context, limit int) ([]models.Au // --- internals --- -//nolint:gocognit func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summary, inspect container.InspectResponse, newRef string) error { dcli, err := s.dockerService.GetClient(ctx) if err != nil { @@ -891,7 +890,7 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ // This method should not be called for Arcane containers if isArcane { slog.ErrorContext(ctx, "updateContainer: called for Arcane container - should use CLI upgrade instead", "containerId", cnt.ID, "containerName", name) - return fmt.Errorf("arcane containers must use CLI upgrade method (TriggerUpgradeViaCLI), not inline update") + return errors.New("arcane containers must use CLI upgrade method (TriggerUpgradeViaCLI), not inline update") } slog.DebugContext(ctx, "updateContainer: starting update", "containerId", cnt.ID, "containerName", name, "newRef", newRef, "isArcane", isArcane) @@ -1054,11 +1053,11 @@ func normalizeImageUpdateRefInternal(imageRef string) string { return parts.NormalizedRef } -func addNormalizedImageUpdateRefInternal(ctx context.Context, out map[string]struct{}, imageRef, logMessage string, attrs ...any) bool { +func addNormalizedImageUpdateRefInternal(ctx context.Context, out map[string]struct{}, imageRef, logMessage string, attrs ...any) { normalizedRef := normalizeImageUpdateRefInternal(imageRef) if normalizedRef != "" { out[normalizedRef] = struct{}{} - return true + return } args := slices.Clone(attrs) @@ -1068,7 +1067,6 @@ func addNormalizedImageUpdateRefInternal(ctx context.Context, out map[string]str } else { slog.Debug(logMessage, args...) } - return false } func (s *UpdaterService) stripDigest(ref string) string { @@ -1361,7 +1359,7 @@ type restartPlan struct { implicit bool } -//nolint:gocognit +//nolint:gocognit,gocyclo,maintidx // restart-orchestration flow remaps old/new container IDs in one pass; refactor tracked separately func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldIDToNewRef map[string]string, oldRefToNewRef map[string]string) ([]updater.ResourceResult, error) { dcli, err := s.dockerService.GetClient(ctx) if err != nil { @@ -1942,14 +1940,14 @@ func (s *UpdaterService) logAutoUpdate(ctx context.Context, sev models.EventSeve img = fmt.Sprint(metadata["imageOld"]) } if img != "" { - title = fmt.Sprintf("Auto-update: image pull %s", img) + title = "Auto-update: image pull " + img } else { title = "Auto-update: image pull" } case "image_prune": imageID := fmt.Sprint(metadata["imageId"]) if imageID != "" { - title = fmt.Sprintf("Auto-update: image prune %s", imageID) + title = "Auto-update: image prune " + imageID } else { title = "Auto-update: image prune" } @@ -1959,7 +1957,7 @@ func (s *UpdaterService) logAutoUpdate(ctx context.Context, sev models.EventSeve name = fmt.Sprint(metadata["containerId"]) } if name != "" { - title = fmt.Sprintf("Auto-update: container %s", name) + title = "Auto-update: container " + name } else { title = "Auto-update: container" } @@ -1969,7 +1967,7 @@ func (s *UpdaterService) logAutoUpdate(ctx context.Context, sev models.EventSeve name = fmt.Sprint(metadata["projectId"]) } if name != "" { - title = fmt.Sprintf("Auto-update: project %s", name) + title = "Auto-update: project " + name } else { title = "Auto-update: project" } diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go index b18601d562..8bdb1dd33c 100644 --- a/backend/internal/services/user_service.go +++ b/backend/internal/services/user_service.go @@ -98,7 +98,7 @@ func (s *UserService) validateBcryptPassword(hash, password string) error { func (s *UserService) validateArgon2Password(encodedHash, password string) error { parts := strings.Split(encodedHash, "$") if len(parts) != 6 { - return fmt.Errorf("invalid hash format") + return errors.New("invalid hash format") } var version int @@ -107,7 +107,7 @@ func (s *UserService) validateArgon2Password(encodedHash, password string) error return err } if version != argon2.Version { - return fmt.Errorf("incompatible version of argon2") + return errors.New("incompatible version of argon2") } var memory, iterations uint32 @@ -129,14 +129,14 @@ func (s *UserService) validateArgon2Password(encodedHash, password string) error hashLen := len(decodedHash) if hashLen < 0 || hashLen > 0x7fffffff { - return fmt.Errorf("invalid hash length") + return errors.New("invalid hash length") } comparisonHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(hashLen)) // constant-time compare if subtle.ConstantTimeCompare(comparisonHash, decodedHash) != 1 { - return fmt.Errorf("invalid password") + return errors.New("invalid password") } return nil @@ -216,7 +216,7 @@ func (s *UserService) AttachOidcSubjectTransactional(ctx context.Context, userID // If already linked to a different subject, abort if u.OidcSubjectId != nil && *u.OidcSubjectId != "" && *u.OidcSubjectId != subject { - return fmt.Errorf("user already linked to another OIDC subject") + return errors.New("user already linked to another OIDC subject") } // Link subject @@ -420,10 +420,7 @@ func (s *UserService) ListUsersPaginated(ctx context.Context, params pagination. return nil, pagination.Response{}, fmt.Errorf("failed to paginate users: %w", err) } - result, err := s.toUserResponseDtosInternal(ctx, users) - if err != nil { - return nil, pagination.Response{}, err - } + result := s.toUserResponseDtosInternal(ctx, users) return result, paginationResp, nil } @@ -432,12 +429,12 @@ func (s *UserService) ToUserResponseDto(ctx context.Context, u models.User) (use return s.toUserResponseDtoInternal(ctx, u), nil } -func (s *UserService) toUserResponseDtosInternal(ctx context.Context, users []models.User) ([]user.User, error) { +func (s *UserService) toUserResponseDtosInternal(ctx context.Context, users []models.User) []user.User { result := make([]user.User, len(users)) for i, u := range users { result[i] = s.toUserResponseDtoInternal(ctx, u) } - return result, nil + return result } // toUserResponseDtoInternal builds the public User DTO. RoleAssignments and diff --git a/backend/internal/services/user_service_test.go b/backend/internal/services/user_service_test.go index e82054f771..3b43c7b8ec 100644 --- a/backend/internal/services/user_service_test.go +++ b/backend/internal/services/user_service_test.go @@ -94,9 +94,9 @@ func TestListUsersPaginatedSetsCanDeleteFromGlobalAdminCount(t *testing.T) { nonAdmin := createTestUser(t, userSvc, "user-1", "user") users, _, err := userSvc.ListUsersPaginated(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - SortParams: pagination.SortParams{Sort: "Username", Order: pagination.SortOrder("asc")}, - Filters: map[string]string{}, + Params: pagination.Params{Start: 0, Limit: 20}, + SortParams: pagination.SortParams{Sort: "Username", Order: pagination.SortOrder("asc")}, + Filters: map[string]string{}, }) require.NoError(t, err) require.Len(t, users, 2) diff --git a/backend/internal/services/version_service.go b/backend/internal/services/version_service.go index e688ad3b56..bfd64e9f22 100644 --- a/backend/internal/services/version_service.go +++ b/backend/internal/services/version_service.go @@ -70,7 +70,7 @@ func (s *VersionService) getLatestReleaseInternal(ctx context.Context) (latestRe return latestRelease{}, fmt.Errorf("create GitHub request: %w", err) } - resp, err := s.httpClient.Do(req) //nolint:gosec // intentional request to fixed GitHub releases API endpoint + resp, err := s.httpClient.Do(req) if err != nil { return latestRelease{}, fmt.Errorf("get latest release: %w", err) } @@ -89,7 +89,7 @@ func (s *VersionService) getLatestReleaseInternal(ctx context.Context) (latestRe return latestRelease{}, fmt.Errorf("decode payload: %w", err) } if payload.TagName == "" { - return latestRelease{}, fmt.Errorf("GitHub API returned empty tag name") + return latestRelease{}, errors.New("GitHub API returned empty tag name") } return latestRelease{ @@ -99,7 +99,7 @@ func (s *VersionService) getLatestReleaseInternal(ctx context.Context) (latestRe }, nil }) - if staleErr, ok := errors.AsType[*cache.ErrStale](err); ok { + if staleErr, ok := errors.AsType[*cache.StaleError](err); ok { slog.Warn("Failed to fetch latest release, returning stale cache", "error", staleErr.Err) return rel, nil } @@ -176,7 +176,7 @@ func (s *VersionService) GetVersionInformation(ctx context.Context, currentVersi latest, err := s.GetLatestVersion(ctx) if err != nil { - if staleErr, ok := errors.AsType[*cache.ErrStale](err); ok { + if staleErr, ok := errors.AsType[*cache.StaleError](err); ok { slog.Warn("Failed to refresh latest version; using stale cache", "error", staleErr.Err) } else { return check, err @@ -207,7 +207,7 @@ func (s *VersionService) isSemverVersion() bool { func (s *VersionService) getDisplayVersion() string { version := strings.TrimPrefix(strings.TrimSpace(s.version), "v") if strings.Contains(strings.ToLower(version), "next") && s.revision != "" && s.revision != "unknown" { - return fmt.Sprintf("next-%s", config.ShortRevision()) + return "next-" + config.ShortRevision() } if s.isSemverVersion() { return "v" + version @@ -246,7 +246,7 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { // For semver versions, check GitHub releases if isSemver { rel, err := s.getLatestReleaseInternal(ctx) - var staleErr *cache.ErrStale + var staleErr *cache.StaleError if err == nil || errors.As(err, &staleErr) { if rel.TagName != "" { info.NewestVersion = rel.TagName diff --git a/backend/internal/services/volume_service.go b/backend/internal/services/volume_service.go index 759a19c7f3..4fd5d90915 100644 --- a/backend/internal/services/volume_service.go +++ b/backend/internal/services/volume_service.go @@ -5,6 +5,7 @@ import ( "bytes" "compress/gzip" "context" + "errors" "fmt" "io" "log/slog" @@ -445,7 +446,7 @@ func (s *VolumeService) getHelperImageInternal(ctx context.Context, dockerClient pullErr = pullImageErr slog.WarnContext(ctx, "volume service: failed to pull tools helper image, attempting arcane fallback", "error", pullImageErr.Error()) } else { - pullErr = fmt.Errorf("image service unavailable") + pullErr = errors.New("image service unavailable") slog.WarnContext(ctx, "volume service: image service unavailable, attempting arcane fallback") } @@ -496,7 +497,7 @@ func resolveBackupStorageMountFromMountsInternal(mounts []container.MountPoint, }, true } -func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, dockerClient *client.Client, target string, readOnly bool) (backupStorageMountInternal, error) { +func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, dockerClient *client.Client, target string, readOnly bool) backupStorageMountInternal { if dockerClient != nil { containerID := s.getArcaneContainerIDInternal(ctx, dockerClient) if containerID != "" { @@ -504,7 +505,7 @@ func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, d if err != nil { slog.WarnContext(ctx, "volume service: failed to inspect arcane container for backup mount resolution, falling back to named volume", "container_id", containerID, "error", err.Error()) } else if resolved, ok := resolveBackupStorageMountFromMountsInternal(inspect.Container.Mounts, target, readOnly); ok { - return resolved, nil + return resolved } } } @@ -518,14 +519,11 @@ func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, d ReadOnly: readOnly, }, requiresEnsure: true, - }, nil + } } func (s *VolumeService) resolveUsableBackupStorageMountInternal(ctx context.Context, dockerClient *client.Client, target string, readOnly bool) (backupStorageMountInternal, error) { - backupStorage, err := s.resolveBackupStorageMountInternal(ctx, dockerClient, target, readOnly) - if err != nil { - return backupStorageMountInternal{}, err - } + backupStorage := s.resolveBackupStorageMountInternal(ctx, dockerClient, target, readOnly) if backupStorage.requiresEnsure { if err := s.ensureBackupVolumeInternal(ctx); err != nil { return backupStorageMountInternal{}, err @@ -668,6 +666,7 @@ func (s *VolumeService) createBackupTempContainerInternal(ctx context.Context, d type cleanupReadCloser struct { io.Reader io.Closer + cleanup func() } @@ -936,7 +935,7 @@ func (s *VolumeService) DeleteFile(ctx context.Context, volumeName, filePath str } // Prevent deleting root if sanitizedPath == "/" { - return fmt.Errorf("cannot delete root directory") + return errors.New("cannot delete root directory") } containerID, cleanup, err := s.createTempContainerInternal(ctx, volumeName, false) @@ -1122,7 +1121,7 @@ func (s *VolumeService) CreateBackup(ctx context.Context, volumeName string, use } hostConfig := s.buildHelperHostConfigInternal(helperImage, []string{ - fmt.Sprintf("%s:/volume:ro", volumeName), + volumeName + ":/volume:ro", }, []mount.Mount{backupStorage.mount}) resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ @@ -1369,7 +1368,7 @@ func (s *VolumeService) RestoreBackup(ctx context.Context, volumeName, backupID } hostConfig := s.buildHelperHostConfigInternal(helperImage, []string{ - fmt.Sprintf("%s:/volume", volumeName), + volumeName + ":/volume", }, []mount.Mount{backupStorage.mount}) resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ @@ -1414,7 +1413,7 @@ func (s *VolumeService) RestoreBackup(ctx context.Context, volumeName, backupID func (s *VolumeService) sanitizeBackupPathInternal(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { - return "", fmt.Errorf("invalid path: empty") + return "", errors.New("invalid path: empty") } cleaned := path.Clean(trimmed) if cleaned == "." || cleaned == "/" { @@ -1435,7 +1434,7 @@ func (s *VolumeService) sanitizeBackupIDInternal(backupID string) (string, error return "", fmt.Errorf("invalid backup id: %w", err) } if strings.Contains(cleaned, "/") { - return "", fmt.Errorf("invalid backup id: path separators not allowed") + return "", errors.New("invalid backup id: path separators not allowed") } return cleaned, nil } @@ -1446,7 +1445,7 @@ func (s *VolumeService) backupArchiveFilenameInternal(backupID string) (string, return "", err } - return fmt.Sprintf("%s.tar.gz", sanitizedBackupID), nil + return sanitizedBackupID + ".tar.gz", nil } // sanitizeBrowsePath validates and cleans a path for file browser operations. @@ -1463,11 +1462,11 @@ func (s *VolumeService) sanitizeBrowsePathInternal(input string) (string, error) } // Check for path traversal attempts if strings.Contains(cleaned, "/../") || strings.HasSuffix(cleaned, "/..") || cleaned == "/.." { - return "", fmt.Errorf("invalid path: path traversal not allowed") + return "", errors.New("invalid path: path traversal not allowed") } // After cleaning, the path should not escape root if !strings.HasPrefix(cleaned, "/") { - return "", fmt.Errorf("invalid path: must be absolute") + return "", errors.New("invalid path: must be absolute") } return cleaned, nil } @@ -1598,7 +1597,7 @@ func (s *VolumeService) ListBackupFiles(ctx context.Context, backupID string) ([ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, backupID string, paths []string, user models.User) error { slog.DebugContext(ctx, "volume service: restore backup files", "volume", volumeName, "backup_id", backupID, "paths_count", len(paths), "user", user.ID) if len(paths) == 0 { - return fmt.Errorf("no paths provided") + return errors.New("no paths provided") } filename, err := s.backupArchiveFilenameInternal(backupID) if err != nil { @@ -1610,7 +1609,7 @@ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, back return err } if backup.VolumeName != volumeName { - return fmt.Errorf("backup does not belong to volume") + return errors.New("backup does not belong to volume") } // Create pre-restore backup for safety (consistent with RestoreBackup behavior) @@ -1629,7 +1628,7 @@ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, back cleanedPaths = append(cleanedPaths, cleaned) } if len(cleanedPaths) == 0 { - return fmt.Errorf("no valid paths provided") + return errors.New("no valid paths provided") } tarPaths := make([]string, 0, len(cleanedPaths)) @@ -1660,7 +1659,7 @@ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, back } hostConfig := s.buildHelperHostConfigInternal(helperImage, []string{ - fmt.Sprintf("%s:/volume", volumeName), + volumeName + ":/volume", }, []mount.Mount{backupStorage.mount}) resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ @@ -1760,7 +1759,7 @@ func (s *VolumeService) UploadAndRestore(ctx context.Context, volumeName string, } defer func() { _ = tmpFile.Close() - _ = os.Remove(tmpFile.Name()) //nolint:gosec // temp file path is generated by os.CreateTemp + _ = os.Remove(tmpFile.Name()) }() if _, err := io.Copy(tmpFile, archive); err != nil { return fmt.Errorf("failed to buffer upload: %w", err) @@ -2209,7 +2208,7 @@ func (s *VolumeService) downloadFileFromContainerInternal( if hdr.FileInfo().IsDir() { _ = reader.Close() cleanup() - return nil, 0, fmt.Errorf("path is a directory") + return nil, 0, errors.New("path is a directory") } return &cleanupReadCloser{ diff --git a/backend/internal/services/volume_service_test.go b/backend/internal/services/volume_service_test.go index 7749162afb..5076d8f01c 100644 --- a/backend/internal/services/volume_service_test.go +++ b/backend/internal/services/volume_service_test.go @@ -250,8 +250,7 @@ func TestResolveBackupStorageMountFromMountsInternal(t *testing.T) { func TestResolveBackupStorageMountInternalFallsBackToNamedVolume(t *testing.T) { svc := &VolumeService{backupVolumeName: "arcane-backups"} - got, err := svc.resolveBackupStorageMountInternal(context.Background(), nil, "/backups", true) - require.NoError(t, err) + got := svc.resolveBackupStorageMountInternal(context.Background(), nil, "/backups", true) require.Equal(t, backupStorageModeNamedVolumeFallback, got.mode) require.Equal(t, mount.TypeVolume, got.mount.Type) require.Equal(t, "arcane-backups", got.mount.Source) diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index 28919e4eb4..45fe897ef5 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -102,7 +102,10 @@ type VulnerabilityService struct { // getImageLock returns a mutex for the given image ID, creating one if needed func (s *VulnerabilityService) getImageLock(imageID string) *sync.Mutex { lock, _ := s.scanLocks.LoadOrStore(imageID, &sync.Mutex{}) - return lock.(*sync.Mutex) + if mu, ok := lock.(*sync.Mutex); ok { + return mu + } + return &sync.Mutex{} } func (s *VulnerabilityService) setScanPhaseInternal(imageID string, phase vulnerability.ScanPhase) { @@ -347,7 +350,7 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag ActivityID: utils.StringPtrFromTrimmed(activityID), } s.applyScanPhaseToResultInternal(pendingResult) - if saveErr := s.saveScanResultWithRetryInternal(scanCtx, pendingResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(scanCtx, pendingResult); saveErr != nil { slog.WarnContext(scanCtx, "failed to save pending scan result", "error", saveErr, @@ -370,15 +373,15 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag return pendingResult, nil } -func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record *models.VulnerabilityScanRecord) bool { +func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record *models.VulnerabilityScanRecord) { if record == nil { - return false + return } if record.Status != models.ScanStatusScanning && record.Status != models.ScanStatusPending { - return false + return } if time.Since(record.ScanTime) <= scanStaleTimeout { - return false + return } errMsg := "Scan timed out or was interrupted. Please retry." @@ -388,7 +391,7 @@ func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record s.clearScanPhaseInternal(record.ID) if s.db == nil { - return true + return } if err := s.db.WithContext(ctx). @@ -400,10 +403,8 @@ func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record "duration": record.Duration, }).Error; err != nil { slog.WarnContext(ctx, "failed to mark stale scan as failed", "error", err, "scan_id", record.ID) - return false + return } - - return true } func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context, envID string, imageID, imageName string, user models.User, activityID string) { @@ -428,9 +429,9 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context ScanTime: time.Now(), Status: vulnerability.ScanStatusFailed, ActivityID: utils.StringPtrFromTrimmed(activityID), - Error: fmt.Sprintf("Trivy scanner is not available: %s", err.Error()), + Error: "Trivy scanner is not available: " + err.Error(), } - if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, result); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } slog.WarnContext(ctx, @@ -462,7 +463,7 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context Error: err.Error(), Duration: duration, } - if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult); saveErr != nil { slog.WarnContext(ctx, "failed to save failed scan result", "error", saveErr) } slog.WarnContext(ctx, @@ -483,7 +484,7 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context s.ensureSummary(result) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Storing vulnerability results") - if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, result); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } @@ -1292,7 +1293,7 @@ func (s *VulnerabilityService) saveScheduledFailedScanInternal( Error: scanErr.Error(), Duration: duration, } - if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult); saveErr != nil { slog.WarnContext(ctx, "failed to save failed scan result", "error", saveErr) } s.logScheduledScanEvent(ctx, envID, imageID, imageName, user, false, scanErr.Error()) @@ -1307,7 +1308,7 @@ func (s *VulnerabilityService) saveScheduledSuccessfulScanInternal( ) { result.Duration = duration s.ensureSummary(result) - if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, result); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } s.logScheduledScanEvent(ctx, envID, imageID, imageName, user, true, "") @@ -1359,7 +1360,7 @@ func (s *VulnerabilityService) sendScheduledVulnerabilitySummaryInternal( summaryDate := time.Now().UTC().Format("2006-01-02") payload := VulnerabilityNotificationPayload{ - CVEID: fmt.Sprintf("Daily Summary - %s", summaryDate), + CVEID: "Daily Summary - " + summaryDate, Severity: fmt.Sprintf("Critical:%d High:%d Medium:%d Low:%d Unknown:%d", summary.severityCounts["CRITICAL"], summary.severityCounts["HIGH"], summary.severityCounts["MEDIUM"], summary.severityCounts["LOW"], summary.severityCounts["UNKNOWN"]), ImageName: fmt.Sprintf("%d image(s) scanned, %d with fixable vulnerabilities", scanned, summary.imagesWithFixable), FixedVersion: fmt.Sprintf("%d fixable vulnerability record(s)", summary.totalFixable), @@ -1552,7 +1553,8 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con outputPath := newTrivyOutputPathInternal() defer cleanupTrivyOutputFileInContainerInternal(ctx, dockerClient, containerID, outputPath) - execCmd := []string{"trivy", "image", "--format", "json", "--quiet", "--output", outputPath, "--timeout", trivyTimeout} + execCmd := make([]string, 0, 16) + execCmd = append(execCmd, "trivy", "image", "--format", "json", "--quiet", "--output", outputPath, "--timeout", trivyTimeout) execCmd = append(execCmd, trivyScanCacheBackendArgsInternal()...) execCmd = append(execCmd, trivyDefaultRepositoryArgsInternal()...) execCmd = append(execCmd, imageName) @@ -1586,9 +1588,9 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con } if execInspect.ExitCode != 0 { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { - errMsg = readTempFileExcerptInternal(stdoutFile, trivyErrorExcerptSize) + errMsg = readTempFileExcerptInternal(stdoutFile) } if errMsg == "" { errMsg = fmt.Sprintf("exit status %d", execInspect.ExitCode) @@ -1616,7 +1618,7 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con } if err != nil { if errors.Is(err, io.EOF) { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -1629,7 +1631,7 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con } if trivyReport == nil { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -1799,7 +1801,7 @@ func (s *VulnerabilityService) GetTrivyVersion(ctx context.Context) string { return "" } - return parseTrivyVersion(readTempFileExcerptInternal(stdoutFile, trivyErrorExcerptSize)) + return parseTrivyVersion(readTempFileExcerptInternal(stdoutFile)) } func parseTrivyVersion(output string) string { @@ -2220,17 +2222,17 @@ func (s *VulnerabilityService) ensureTrivyCacheVolumeInternal(ctx context.Contex } // runTrivyScan executes Trivy scan on an image -func (s *VulnerabilityService) getTrivyConfigFiles() (configContent, ignoreContent string, err error) { +func (s *VulnerabilityService) getTrivyConfigFiles() (configContent, ignoreContent string) { if s.settingsService == nil { - return "", "", nil + return "", "" } cfg := s.settingsService.GetSettingsConfig() if cfg == nil { - return "", "", nil + return "", "" } - return cfg.TrivyConfig.Value, cfg.TrivyIgnore.Value, nil + return cfg.TrivyConfig.Value, cfg.TrivyIgnore.Value } func (s *VulnerabilityService) createTrivyConfigTempFile(ctx context.Context, content string) (string, bool) { @@ -2242,7 +2244,7 @@ func (s *VulnerabilityService) createTrivyConfigTempFile(ctx context.Context, co if _, err := configFile.WriteString(content); err != nil { slog.WarnContext(ctx, "failed to write trivy config", "error", err) _ = configFile.Close() - _ = os.Remove(configFile.Name()) //nolint:gosec // temp file path is generated by os.CreateTemp + _ = os.Remove(configFile.Name()) return "", false } _ = configFile.Close() @@ -2258,7 +2260,7 @@ func (s *VulnerabilityService) createTrivyIgnoreTempFile(ctx context.Context, co if _, err := ignoreFile.WriteString(content); err != nil { slog.WarnContext(ctx, "failed to write trivy ignore", "error", err) _ = ignoreFile.Close() - _ = os.Remove(ignoreFile.Name()) //nolint:gosec // temp file path is generated by os.CreateTemp + _ = os.Remove(ignoreFile.Name()) return "", false } _ = ignoreFile.Close() @@ -2495,7 +2497,7 @@ func cleanupTrivyLogTempFilesInternal(ctx context.Context, files ...*os.File) { continue } - if err := os.Remove(path); err != nil { //nolint:gosec // temp file path comes from os.CreateTemp + if err := os.Remove(path); err != nil { slog.WarnContext(ctx, "failed to remove trivy temp file", "path", path, "error", err) } } @@ -2896,7 +2898,7 @@ func tempFileSizeInternal(file *os.File) int64 { return stat.Size() } -func readTempFileExcerptInternal(file *os.File, maxBytes int64) string { +func readTempFileExcerptInternal(file *os.File) string { if file == nil { return "" } @@ -2905,12 +2907,7 @@ func readTempFileExcerptInternal(file *os.File, maxBytes int64) string { return "" } - reader := io.Reader(file) - if maxBytes > 0 { - reader = io.LimitReader(file, maxBytes) - } - - content, err := io.ReadAll(reader) + content, err := io.ReadAll(io.LimitReader(file, trivyErrorExcerptSize)) if err != nil { return "" } @@ -3422,10 +3419,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri } // Get Trivy config files if they exist - configContent, ignoreContent, err := s.getTrivyConfigFiles() - if err != nil { - slog.WarnContext(ctx, "failed to get trivy config files", "error", err) - } + configContent, ignoreContent := s.getTrivyConfigFiles() cacheDir := s.getTrivySingleScanCacheDirForSlotInternal(slotID) if cacheDir != "" { @@ -3476,9 +3470,9 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri defer removeDockerContainerInternal(ctx, dockerClient, containerID, "failed to cleanup trivy scan container") if statusCode != 0 { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { - errMsg = readTempFileExcerptInternal(stdoutFile, trivyErrorExcerptSize) + errMsg = readTempFileExcerptInternal(stdoutFile) } if errMsg == "" { errMsg = fmt.Sprintf("exit status %d", statusCode) @@ -3505,7 +3499,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri } if err != nil { if errors.Is(err, io.EOF) { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -3518,7 +3512,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri } if trivyReport == nil { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -3549,15 +3543,11 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri func (s *VulnerabilityService) saveScanResultWithRetryInternal( ctx context.Context, result *vulnerability.ScanResult, - maxAttempts int, - retryDelay time.Duration, ) error { - if maxAttempts <= 0 { - maxAttempts = 1 - } - if retryDelay < 0 { - retryDelay = 0 - } + const ( + maxAttempts = defaultScanSaveRetryAttempts + retryDelay = defaultScanSaveRetryDelay + ) var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { diff --git a/backend/internal/services/vulnerability_service_test.go b/backend/internal/services/vulnerability_service_test.go index 13148d68ab..8e68fc29ad 100644 --- a/backend/internal/services/vulnerability_service_test.go +++ b/backend/internal/services/vulnerability_service_test.go @@ -1004,7 +1004,7 @@ func TestVulnerabilityService_ListAllVulnerabilities_FiltersIgnoredInline(t *tes require.NoError(t, db.Create(&ignore).Error) items, page, err := svc.ListAllVulnerabilities(ctx, "env-1", pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, + Params: pagination.Params{Start: 0, Limit: 20}, }) require.NoError(t, err) require.Empty(t, items) diff --git a/backend/internal/services/webhook_service.go b/backend/internal/services/webhook_service.go index 52bab3f8b6..570be01da5 100644 --- a/backend/internal/services/webhook_service.go +++ b/backend/internal/services/webhook_service.go @@ -202,7 +202,7 @@ func (s *WebhookService) CreateWebhook(ctx context.Context, name, targetType, ac _, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeWebhookCreate, Severity: models.EventSeveritySuccess, - Title: fmt.Sprintf("Webhook created: %s", wh.Name), + Title: "Webhook created: " + wh.Name, Description: fmt.Sprintf("Created webhook '%s' targeting %s (%s)", wh.Name, wh.TargetType, wh.ActionType), ResourceType: new("webhook"), ResourceID: &wh.ID, @@ -337,7 +337,7 @@ func (s *WebhookService) DeleteWebhook(ctx context.Context, id, environmentID st _, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeWebhookDelete, Severity: models.EventSeverityInfo, - Title: fmt.Sprintf("Webhook deleted: %s", wh.Name), + Title: "Webhook deleted: " + wh.Name, Description: fmt.Sprintf("Deleted webhook '%s'", wh.Name), ResourceType: new("webhook"), ResourceID: &wh.ID, @@ -372,7 +372,7 @@ func (s *WebhookService) UpdateWebhook(ctx context.Context, id, environmentID st _, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeWebhookUpdate, Severity: models.EventSeveritySuccess, - Title: fmt.Sprintf("Webhook updated: %s", wh.Name), + Title: "Webhook updated: " + wh.Name, Description: fmt.Sprintf("Updated webhook '%s' enabled=%v", wh.Name, enabled), ResourceType: new("webhook"), ResourceID: &wh.ID, @@ -430,7 +430,7 @@ func (s *WebhookService) TriggerByToken(ctx context.Context, rawToken string) (* // Record trigger time — best-effort, do not fail the request if this update fails. now := time.Now() - _ = s.db.WithContext(ctx).Model(wh).Update("last_triggered_at", now).Error //nolint:errcheck + _ = s.db.WithContext(ctx).Model(wh).Update("last_triggered_at", now).Error s.logWebhookEventInternal(ctx, wh, actionType, models.EventSeveritySuccess, "") @@ -550,7 +550,7 @@ func (s *WebhookService) syncWebhookContainerTargetInternal(ctx context.Context, return } - _ = s.db.WithContext(ctx).Model(wh).Updates(updates).Error //nolint:errcheck + _ = s.db.WithContext(ctx).Model(wh).Updates(updates).Error } func (s *WebhookService) executeProjectWebhookActionInternal(ctx context.Context, wh *models.Webhook, actionType string) (*updater.Result, error) { @@ -620,9 +620,9 @@ func (s *WebhookService) logWebhookEventInternal(ctx context.Context, wh *models if s.eventService == nil { return } - title := fmt.Sprintf("Webhook triggered: %s", wh.Name) + title := "Webhook triggered: " + wh.Name if severity == models.EventSeverityError { - title = fmt.Sprintf("Webhook trigger failed: %s", wh.Name) + title = "Webhook trigger failed: " + wh.Name } description := fmt.Sprintf("Target type: %s, action: %s", wh.TargetType, actionType) if errMsg != "" { diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go index 94db9a094d..cd45b8c55f 100644 --- a/backend/pkg/authz/permissions.go +++ b/backend/pkg/authz/permissions.go @@ -24,11 +24,12 @@ const ( PermUsersUpdate = "users:update" PermUsersDelete = "users:delete" - // Role management (Create / Update / Delete) and role assignment to users - // are reserved for global admins and intentionally not exposed as delegated - // permissions — see backend/api/middleware/role.go::RequireGlobalAdmin. - // Likewise, managing OIDC group → role mappings is admin-only because it - // is effectively another path for granting role assignments. + // PermRolesList and the role permissions below cover role management + // (Create / Update / Delete) and role assignment to users. They are reserved + // for global admins and intentionally not exposed as delegated permissions — + // see backend/api/middleware/role.go::RequireGlobalAdmin. Likewise, managing + // OIDC group → role mappings is admin-only because it is effectively another + // path for granting role assignments. PermRolesList = "roles:list" PermRolesRead = "roles:read" diff --git a/backend/pkg/dockerutil/cgroup_utils.go b/backend/pkg/dockerutil/cgroup_utils.go index ae38058898..eee826987b 100644 --- a/backend/pkg/dockerutil/cgroup_utils.go +++ b/backend/pkg/dockerutil/cgroup_utils.go @@ -34,7 +34,7 @@ func DetectCgroupLimits() (*CgroupLimits, error) { } if !isInCgroup() { - return nil, fmt.Errorf("not running in a cgroup") + return nil, errors.New("not running in a cgroup") } if isCgroupV2() { @@ -178,7 +178,7 @@ func getCgroupV1Path() (string, error) { return "", fmt.Errorf("error scanning cgroup file: %w", err) } - return "", fmt.Errorf("cgroup path not found") + return "", errors.New("cgroup path not found") } func readCgroupV1Int64(path string) (int64, error) { diff --git a/backend/pkg/fswatch/watcher.go b/backend/pkg/fswatch/watcher.go index 1a17a5d6be..2cbc9ea5bb 100644 --- a/backend/pkg/fswatch/watcher.go +++ b/backend/pkg/fswatch/watcher.go @@ -2,7 +2,7 @@ package fswatch import ( "context" - "fmt" + "errors" "log/slog" "os" "path/filepath" @@ -79,7 +79,7 @@ func (fw *Watcher) Start(ctx context.Context) error { return nil } if fw.stopped { - return fmt.Errorf("watcher already stopped") + return errors.New("watcher already stopped") } if err := fw.watcher.Add(fw.watchedPath); err != nil { diff --git a/backend/pkg/gitutil/git.go b/backend/pkg/gitutil/git.go index f2d3021f52..9ff7e007d6 100644 --- a/backend/pkg/gitutil/git.go +++ b/backend/pkg/gitutil/git.go @@ -86,7 +86,7 @@ func (c *Client) getAuth(config AuthConfig) (transport.AuthMethod, error) { return publicKeys, nil } - return nil, fmt.Errorf("ssh key required for ssh authentication") + return nil, errors.New("ssh key required for ssh authentication") case "none": return nil, nil default: @@ -410,7 +410,7 @@ func ValidatePath(repoPath, requestedPath string) error { return fmt.Errorf("invalid path: %w", err) } if strings.HasPrefix(rel, "..") || strings.Contains(rel, string(filepath.Separator)+".."+string(filepath.Separator)) { - return fmt.Errorf("path traversal attempt detected") + return errors.New("path traversal attempt detected") } return nil @@ -435,7 +435,7 @@ func (c *Client) BrowseTree(ctx context.Context, repoPath, targetPath string) ([ } if !info.IsDir() { - return nil, fmt.Errorf("path is not a directory") + return nil, errors.New("path is not a directory") } entries, err := os.ReadDir(fullPath) @@ -604,7 +604,7 @@ func (c *Client) WalkDirectory(ctx context.Context, repoPath, composePath string // Validate we found at least one file if len(result.Files) == 0 { - return nil, fmt.Errorf("no files found in sync directory (directory may be empty or all files were skipped)") + return nil, errors.New("no files found in sync directory (directory may be empty or all files were skipped)") } return result, nil diff --git a/backend/pkg/libarcane/activity/writer.go b/backend/pkg/libarcane/activity/writer.go index 2fb4c8052f..993cfde5ba 100644 --- a/backend/pkg/libarcane/activity/writer.go +++ b/backend/pkg/libarcane/activity/writer.go @@ -210,7 +210,7 @@ func (w *Writer) updateLayerProgressInternal(id, status string, rawDetail any) * statusLower := strings.ToLower(item.status) switch { case layerCompleteInternal(statusLower): - weighted += 1 + weighted++ case strings.Contains(statusLower, "extracting"): weighted += 0.95 case strings.Contains(statusLower, "verifying"): @@ -224,10 +224,7 @@ func (w *Writer) updateLayerProgressInternal(id, status string, rawDetail any) * } } - progress := min(int((weighted/float64(len(w.layers)))*100), 100) - if progress < 0 { - progress = 0 - } + progress := max(min(int((weighted/float64(len(w.layers)))*100), 100), 0) return &progress } @@ -420,7 +417,7 @@ func containerEventMessageInternal(payload map[string]any) string { if state != "" { return fmt.Sprintf("Container %s: %s", service, state) } - return fmt.Sprintf("Container %s", service) + return "Container " + service } func fallbackStepInternal(value, fallback string) string { diff --git a/backend/pkg/libarcane/crypto/encryption.go b/backend/pkg/libarcane/crypto/encryption.go index 6987ed60ad..b838052bf7 100644 --- a/backend/pkg/libarcane/crypto/encryption.go +++ b/backend/pkg/libarcane/crypto/encryption.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "fmt" "io" "log/slog" @@ -145,7 +146,7 @@ func atomicWriteHexFile(path string, key []byte, mode os.FileMode) error { if err := tmp.Close(); err != nil { return err } - if err := os.Rename(tmpPath, path); err != nil { //nolint:gosec // destination path is internally derived from app-controlled config directory + if err := os.Rename(tmpPath, path); err != nil { return err } if err := os.Chmod(path, mode); err != nil { @@ -163,7 +164,7 @@ func deriveDevKey() []byte { // Encrypt encrypts a plaintext string using AES-GCM func Encrypt(plaintext string) (string, error) { if encryptionKey == nil { - return "", fmt.Errorf("encryption not initialized - call InitEncryption first") + return "", errors.New("encryption not initialized - call InitEncryption first") } if plaintext == "" { @@ -192,7 +193,7 @@ func Encrypt(plaintext string) (string, error) { // Decrypt decrypts a base64 encoded ciphertext string using AES-GCM func Decrypt(ciphertext string) (string, error) { if encryptionKey == nil { - return "", fmt.Errorf("encryption not initialized - call InitEncryption first") + return "", errors.New("encryption not initialized - call InitEncryption first") } if ciphertext == "" { @@ -216,7 +217,7 @@ func Decrypt(ciphertext string) (string, error) { nonceSize := gcm.NonceSize() if len(data) < nonceSize { - return "", fmt.Errorf("ciphertext too short") + return "", errors.New("ciphertext too short") } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] diff --git a/backend/pkg/libarcane/docker_compat.go b/backend/pkg/libarcane/docker_compat.go index 0fe079f82c..e1ebaec493 100644 --- a/backend/pkg/libarcane/docker_compat.go +++ b/backend/pkg/libarcane/docker_compat.go @@ -2,6 +2,7 @@ package libarcane import ( "context" + "errors" "fmt" "slices" "strconv" @@ -82,7 +83,7 @@ func IsDockerAPIVersionAtLeast(current, minimum string) bool { return false } - for i := range len(cur) { + for i := range cur { if cur[i] > minV[i] { return true } @@ -227,7 +228,7 @@ func ContainerCreateWithCompatibility(ctx context.Context, dockerClient client.A // already resolved the daemon API version. func ContainerCreateWithCompatibilityForAPIVersion(ctx context.Context, dockerClient client.APIClient, options client.ContainerCreateOptions, apiVersion string) (client.ContainerCreateResult, error) { if dockerClient == nil { - return client.ContainerCreateResult{}, fmt.Errorf("docker api client is nil") + return client.ContainerCreateResult{}, errors.New("docker api client is nil") } adjustedOptions, extraEndpoints := PrepareContainerCreateOptionsForDockerAPI(options, apiVersion) diff --git a/backend/pkg/libarcane/dockerrun/dockerrun.go b/backend/pkg/libarcane/dockerrun/dockerrun.go index 31619ef390..981d3964eb 100644 --- a/backend/pkg/libarcane/dockerrun/dockerrun.go +++ b/backend/pkg/libarcane/dockerrun/dockerrun.go @@ -1,6 +1,7 @@ package dockerrun import ( + "errors" "fmt" "strings" @@ -20,7 +21,7 @@ func ParseTokens(tokens []string, result *systemtypes.DockerRunCommand) error { } else { if result.Image == "" { if token == "" { - return fmt.Errorf("image name cannot be empty") + return errors.New("image name cannot be empty") } result.Image = token } else { diff --git a/backend/pkg/libarcane/edge/client.go b/backend/pkg/libarcane/edge/client.go index f4d5afbe8b..3c24c313ab 100644 --- a/backend/pkg/libarcane/edge/client.go +++ b/backend/pkg/libarcane/edge/client.go @@ -3,6 +3,7 @@ package edge import ( "bytes" "context" + "errors" "fmt" "io" "log/slog" @@ -228,7 +229,7 @@ func (c *TunnelClient) connectAndServeManagedTunnelInternal(ctx context.Context) if c.shouldAttemptWebSocketTunnelInternal() { return c.connectAndServeWebSocket(ctx) } - return fmt.Errorf("no edge tunnel transport is available") + return errors.New("no edge tunnel transport is available") } func (c *TunnelClient) shouldFallbackToWebSocketInternal() bool { @@ -355,7 +356,7 @@ func (c *TunnelClient) registerMessageInternal() *TunnelMessage { func (c *TunnelClient) awaitRegistrationInternal(ctx context.Context) (*TunnelMessage, error) { if c == nil || c.conn == nil { - return nil, fmt.Errorf("edge tunnel connection is not initialized") + return nil, errors.New("edge tunnel connection is not initialized") } conn := c.conn @@ -386,7 +387,7 @@ func (c *TunnelClient) awaitRegistrationInternal(ctx context.Context) (*TunnelMe return nil, fmt.Errorf("failed to receive tunnel registration response: %w", result.err) } if result.msg == nil { - return nil, fmt.Errorf("received empty tunnel registration response") + return nil, errors.New("received empty tunnel registration response") } if result.msg.Type != MessageTypeRegisterResponse { return nil, fmt.Errorf("unexpected first tunnel message: %s", result.msg.Type) @@ -940,7 +941,10 @@ func (c *TunnelClient) handleStreamData(ctx context.Context, msg *TunnelMessage) slog.DebugContext(ctx, "Received WebSocket data for unknown stream", "stream_id", msg.ID) return } - stream := streamRaw.(*activeWSStream) + stream, ok := streamRaw.(*activeWSStream) + if !ok { + return + } stream.mu.Lock() if stream.closed { stream.mu.Unlock() @@ -966,7 +970,10 @@ func (c *TunnelClient) handleStreamClose(ctx context.Context, msg *TunnelMessage if !ok { return } - stream := streamRaw.(*activeWSStream) + stream, ok := streamRaw.(*activeWSStream) + if !ok { + return + } c.closeWebSocketStream(msg.ID, stream) slog.DebugContext(ctx, "Closed WebSocket stream", "stream_id", msg.ID) } @@ -1285,25 +1292,25 @@ func (r *streamingResponseRecorder) writeHeaderLocked(statusCode int) error { // StartTunnelClientWithErrors starts the tunnel client and returns a channel for connection errors. func StartTunnelClientWithErrors(ctx context.Context, cfg *Config, handler http.Handler) (<-chan error, error) { if !cfg.EdgeAgent { - return nil, fmt.Errorf("edge tunnel disabled") + return nil, errors.New("edge tunnel disabled") } if UseGRPCEdgeTransport(cfg) { if cfg.GetManagerGRPCAddr() == "" { - return nil, fmt.Errorf("MANAGER_API_URL with a valid host is required for gRPC transport") + return nil, errors.New("MANAGER_API_URL with a valid host is required for gRPC transport") } } if UseWebSocketEdgeTransport(cfg) && strings.TrimSpace(cfg.GetManagerBaseURL()) == "" { - return nil, fmt.Errorf("MANAGER_API_URL is required for websocket transport") + return nil, errors.New("MANAGER_API_URL is required for websocket transport") } if UsePollEdgeTransport(cfg) && strings.TrimSpace(cfg.GetManagerBaseURL()) == "" { - return nil, fmt.Errorf("MANAGER_API_URL is required for poll transport") + return nil, errors.New("MANAGER_API_URL is required for poll transport") } if cfg.AgentToken == "" { - return nil, fmt.Errorf("AGENT_TOKEN is required") + return nil, errors.New("AGENT_TOKEN is required") } if err := EnsureAgentMTLSAssets(ctx, cfg); err != nil { diff --git a/backend/pkg/libarcane/edge/client_transport_grpc.go b/backend/pkg/libarcane/edge/client_transport_grpc.go index 1d0645372f..a3410f53b6 100644 --- a/backend/pkg/libarcane/edge/client_transport_grpc.go +++ b/backend/pkg/libarcane/edge/client_transport_grpc.go @@ -3,6 +3,7 @@ package edge import ( "context" "crypto/tls" + "errors" "fmt" "log/slog" "net/url" @@ -21,7 +22,7 @@ import ( func (c *TunnelClient) connectAndServeGRPC(ctx context.Context) error { managerAddr := strings.TrimSpace(c.managerGRPCAddr) if managerAddr == "" { - return fmt.Errorf("manager gRPC address is empty") + return errors.New("manager gRPC address is empty") } dialOpts := []grpc.DialOption{ @@ -47,7 +48,7 @@ func (c *TunnelClient) connectAndServeGRPC(ctx context.Context) error { return fmt.Errorf("failed to configure edge gRPC TLS: %w", err) } if tlsConfig == nil { - tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} //nolint:gosec + tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} } dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) } else { diff --git a/backend/pkg/libarcane/edge/client_transport_poll.go b/backend/pkg/libarcane/edge/client_transport_poll.go index bc0f5dfc85..f07885aacd 100644 --- a/backend/pkg/libarcane/edge/client_transport_poll.go +++ b/backend/pkg/libarcane/edge/client_transport_poll.go @@ -25,7 +25,7 @@ type pollManagedTunnelSession struct { func (c *TunnelClient) connectAndServePoll(ctx context.Context) error { managerBaseURL := strings.TrimRight(strings.TrimSpace(c.cfg.GetManagerBaseURL()), "/") if managerBaseURL == "" { - return fmt.Errorf("manager base URL is empty") + return errors.New("manager base URL is empty") } httpClient, err := NewManagerHTTPClient(c.cfg, 0) if err != nil { @@ -202,7 +202,7 @@ func (c *TunnelClient) pollTunnelControlInternal(ctx context.Context, pollURL st } func (c *TunnelClient) startPollManagedSessionInternal(ctx context.Context) *pollManagedTunnelSession { - sessionCtx, cancel := context.WithCancel(ctx) //nolint:gosec // helper intentionally returns the cancel func via the managed session. + sessionCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { @@ -228,7 +228,7 @@ func (c *TunnelClient) stopPollManagedSessionInternal(ctx context.Context, sessi return err case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("timed out waiting for poll-managed websocket session to stop") + return errors.New("timed out waiting for poll-managed websocket session to stop") } return ctx.Err() } diff --git a/backend/pkg/libarcane/edge/client_transport_websocket.go b/backend/pkg/libarcane/edge/client_transport_websocket.go index 76587a6cef..1183abf32d 100644 --- a/backend/pkg/libarcane/edge/client_transport_websocket.go +++ b/backend/pkg/libarcane/edge/client_transport_websocket.go @@ -2,6 +2,7 @@ package edge import ( "context" + "errors" "fmt" "io" "log/slog" @@ -15,7 +16,7 @@ import ( func (c *TunnelClient) connectAndServeWebSocket(ctx context.Context) error { managerWSURL := c.managerWebSocketURLInternal() if managerWSURL == "" { - return fmt.Errorf("manager WebSocket URL is empty") + return errors.New("manager WebSocket URL is empty") } c.managerURL = managerWSURL diff --git a/backend/pkg/libarcane/edge/command_client.go b/backend/pkg/libarcane/edge/command_client.go index 5b8ca9b618..28ac01f5da 100644 --- a/backend/pkg/libarcane/edge/command_client.go +++ b/backend/pkg/libarcane/edge/command_client.go @@ -2,6 +2,7 @@ package edge import ( "context" + "errors" "fmt" "net/http" "time" @@ -34,13 +35,13 @@ func NewCommandClient() *CommandClient { func (c *CommandClient) Execute(ctx context.Context, tunnel *AgentTunnel, req *CommandRequest) (*CommandResult, error) { if ctx == nil { - return nil, fmt.Errorf("context is required") + return nil, errors.New("context is required") } if err := validateConnectedTunnelInternal(tunnel); err != nil { return nil, err } if req == nil { - return nil, fmt.Errorf("command request is required") + return nil, errors.New("command request is required") } commandName := req.Command @@ -100,16 +101,16 @@ func (c *CommandClient) Execute(ctx context.Context, tunnel *AgentTunnel, req *C func (c *CommandClient) OpenStream(ctx context.Context, tunnel *AgentTunnel, req *CommandRequest) error { if ctx == nil { - return fmt.Errorf("context is required") + return errors.New("context is required") } if err := validateConnectedTunnelInternal(tunnel); err != nil { return err } if req == nil { - return fmt.Errorf("command request is required") + return errors.New("command request is required") } if req.ID == "" { - return fmt.Errorf("stream ID is required") + return errors.New("stream ID is required") } commandName := req.Command @@ -143,7 +144,7 @@ var DefaultCommandClient = NewCommandClient() func validateConnectedTunnelInternal(tunnel *AgentTunnel) error { if tunnel == nil || tunnel.Conn == nil || tunnel.Conn.IsClosed() { - return fmt.Errorf("edge tunnel is not connected") + return errors.New("edge tunnel is not connected") } return nil } diff --git a/backend/pkg/libarcane/edge/event_sync.go b/backend/pkg/libarcane/edge/event_sync.go index 10733c295f..173ca0d94f 100644 --- a/backend/pkg/libarcane/edge/event_sync.go +++ b/backend/pkg/libarcane/edge/event_sync.go @@ -2,7 +2,6 @@ package edge import ( "errors" - "fmt" "strings" "sync" @@ -40,13 +39,13 @@ func getActiveAgentTunnelConn() TunnelConnection { // PublishEventToManager sends an event from the active agent tunnel to the manager. func PublishEventToManager(event *TunnelEvent) error { if event == nil { - return fmt.Errorf("event is required") + return errors.New("event is required") } if strings.TrimSpace(event.Type) == "" { - return fmt.Errorf("event type is required") + return errors.New("event type is required") } if strings.TrimSpace(event.Title) == "" { - return fmt.Errorf("event title is required") + return errors.New("event title is required") } conn := getActiveAgentTunnelConn() diff --git a/backend/pkg/libarcane/edge/proxy.go b/backend/pkg/libarcane/edge/proxy.go index c2e006d7b5..8a8e38188a 100644 --- a/backend/pkg/libarcane/edge/proxy.go +++ b/backend/pkg/libarcane/edge/proxy.go @@ -46,7 +46,7 @@ func ProxyRequest(ctx context.Context, tunnel *AgentTunnel, method, path, query func registerPendingRequestInternal(tunnel *AgentTunnel, requestID string) (<-chan *TunnelMessage, error) { if requestID == "" { - return nil, fmt.Errorf("request ID is required") + return nil, errors.New("request ID is required") } respCh := make(chan *TunnelMessage, 256) @@ -79,9 +79,8 @@ func collectCommandResponseInternal(ctx context.Context, respCh <-chan *TunnelMe case MessageTypeCommandOutput, MessageTypeStreamData, MessageTypeFileChunk: state.handleStreamData(incoming) case MessageTypeCommandComplete: - if done, status, headers, body, err := state.handleCommandComplete(incoming); done { - return status, headers, body, err - } + status, headers, body, err := state.handleCommandComplete(incoming) + return status, headers, body, err case MessageTypeStreamEnd: if done, status, headers, body := state.handleStreamEnd(); done { return status, headers, body, nil @@ -151,7 +150,7 @@ func (s *grpcResponseState) handleStreamEnd() (bool, int, map[string]string, []b return true, s.status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes() } -func (s *grpcResponseState) handleCommandComplete(incoming *TunnelMessage) (bool, int, map[string]string, []byte, error) { +func (s *grpcResponseState) handleCommandComplete(incoming *TunnelMessage) (int, map[string]string, []byte, error) { if !s.gotResponse { s.gotResponse = true s.status = incoming.Status @@ -161,9 +160,9 @@ func (s *grpcResponseState) handleCommandComplete(incoming *TunnelMessage) (bool s.respBody.Write(incoming.Body) } if incoming.Error != "" && incoming.Status >= http.StatusBadRequest { - return true, incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), errors.New(incoming.Error) + return incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), errors.New(incoming.Error) } - return true, incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), nil + return incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), nil } // ProxyHTTPRequest is a helper that proxies an echo context through a tunnel diff --git a/backend/pkg/libarcane/edge/remenv.go b/backend/pkg/libarcane/edge/remenv.go index 5ed8a8c250..6253b5d268 100644 --- a/backend/pkg/libarcane/edge/remenv.go +++ b/backend/pkg/libarcane/edge/remenv.go @@ -10,7 +10,7 @@ import ( ) const ( - HeaderAPIKey = "X-API-Key" // #nosec G101: header name, not a credential + HeaderAPIKey = "X-Api-Key" // #nosec G101: header name, not a credential HeaderAuthorization = "Authorization" HeaderCookie = "Cookie" HeaderAgentToken = "X-Arcane-Agent-Token" // #nosec G101: header name, not a credential diff --git a/backend/pkg/libarcane/edge/server.go b/backend/pkg/libarcane/edge/server.go index 3cc46b9a4d..13910a05a5 100644 --- a/backend/pkg/libarcane/edge/server.go +++ b/backend/pkg/libarcane/edge/server.go @@ -516,7 +516,10 @@ func (s *TunnelServer) handleHeartbeat(ctx context.Context, tunnel *AgentTunnel, func (s *TunnelServer) deliverResponse(ctx context.Context, tunnel *AgentTunnel, msg *TunnelMessage) { if req, ok := tunnel.Pending.Load(msg.ID); ok { - pending := req.(*PendingRequest) + pending, isPending := req.(*PendingRequest) + if !isPending { + return + } select { case pending.ResponseCh <- msg: default: @@ -529,7 +532,10 @@ func (s *TunnelServer) deliverResponse(ctx context.Context, tunnel *AgentTunnel, func (s *TunnelServer) deliverStream(ctx context.Context, tunnel *AgentTunnel, msg *TunnelMessage) { if req, ok := tunnel.Pending.Load(msg.ID); ok { - pending := req.(*PendingRequest) + pending, isPending := req.(*PendingRequest) + if !isPending { + return + } select { case pending.ResponseCh <- msg: case <-ctx.Done(): @@ -652,6 +658,7 @@ func (s *TunnelServer) recoveryStreamInterceptorInternal(ctx context.Context) gr type contextualServerStream struct { grpc.ServerStream + ctx context.Context } diff --git a/backend/pkg/libarcane/edge/tls.go b/backend/pkg/libarcane/edge/tls.go index cecfa8f874..075750c868 100644 --- a/backend/pkg/libarcane/edge/tls.go +++ b/backend/pkg/libarcane/edge/tls.go @@ -105,7 +105,11 @@ func BuildManagerServerTLSConfig(cfg *Config) (*tls.Config, error) { // NewManagerHTTPClient creates an HTTP client for agent-to-manager requests, // applying edge TLS settings when the manager URL uses HTTPS. func NewManagerHTTPClient(cfg *Config, timeout time.Duration) (*http.Client, error) { - transport := http.DefaultTransport.(*http.Transport).Clone() + baseTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, &common.DefaultTransportTypeError{} + } + transport := baseTransport.Clone() tlsConfig, err := buildManagerClientTLSConfigInternal(cfg) if err != nil { return nil, err @@ -144,7 +148,7 @@ func PrepareManagerMTLSAssetsWithContext(ctx context.Context, cfg *Config) error // GeneratedManagerMTLSCAPath returns the configured or Arcane-managed manager CA path without creating assets. func GeneratedManagerMTLSCAPath(cfg *Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("edge config is required") + return "", errors.New("edge config is required") } if configured := strings.TrimSpace(cfg.EdgeMTLSCAFile); configured != "" { return configured, nil @@ -162,7 +166,7 @@ func GenerateManagerClientMTLSAssetsWithContext(ctx context.Context, cfg *Config return nil, nil } if strings.TrimSpace(envID) == "" { - return nil, fmt.Errorf("environment ID is required") + return nil, errors.New("environment ID is required") } assetsDir, err := edgeMTLSAssetsDirInternal(cfg) @@ -243,7 +247,7 @@ func managerMTLSEnrollmentMarkerPathInternal(cfg *Config, envID string) (string, } safeEnvID := generatedAssetNameSanitizer.ReplaceAllString(strings.TrimSpace(envID), "_") if safeEnvID == "" { - return "", fmt.Errorf("environment ID is required") + return "", errors.New("environment ID is required") } return filepath.Join(assetsDir, generatedClientMTLSSubdir, safeEnvID, generatedMTLSEnrolledName), nil } @@ -273,7 +277,7 @@ func GeneratedManagerClientMTLSCertPath(cfg *Config, envID string) (string, erro safeEnvID := generatedAssetNameSanitizer.ReplaceAllString(strings.TrimSpace(envID), "_") if safeEnvID == "" { - return "", fmt.Errorf("environment ID is required") + return "", errors.New("environment ID is required") } return filepath.Join(assetsDir, "clients", safeEnvID, generatedMTLSClientCertName), nil @@ -322,10 +326,10 @@ func EnsureAgentMTLSAssets(ctx context.Context, cfg *Config) error { func enrollAgentMTLSAssetsInternal(ctx context.Context, cfg *Config, assetsDir, certPath, keyPath string) error { managerBaseURL := strings.TrimRight(strings.TrimSpace(cfg.GetManagerBaseURL()), "/") if managerBaseURL == "" { - return fmt.Errorf("MANAGER_API_URL is required to enroll edge mTLS assets") + return errors.New("MANAGER_API_URL is required to enroll edge mTLS assets") } if !managerUsesTLSInternal(cfg) { - return fmt.Errorf("EDGE_MTLS_MODE requires MANAGER_API_URL to use https for certificate enrollment") + return errors.New("EDGE_MTLS_MODE requires MANAGER_API_URL to use https for certificate enrollment") } httpClient, err := NewManagerHTTPClient(cfg, 30*time.Second) @@ -341,7 +345,7 @@ func enrollAgentMTLSAssetsInternal(ctx context.Context, cfg *Config, assetsDir, req.Header.Set(HeaderAPIKey, cfg.AgentToken) req.Header.Set(HeaderAuthorization, "Bearer "+cfg.AgentToken) - resp, err := httpClient.Do(req) //nolint:gosec // intentional request to configured manager endpoint + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("edge mTLS enrollment request failed: %w", err) } @@ -357,7 +361,7 @@ func enrollAgentMTLSAssetsInternal(ctx context.Context, cfg *Config, assetsDir, return fmt.Errorf("failed to decode edge mTLS enrollment response: %w", err) } if len(enrollResp.Files) == 0 { - return fmt.Errorf("edge mTLS enrollment response did not include any files") + return errors.New("edge mTLS enrollment response did not include any files") } if err := os.MkdirAll(assetsDir, common.DirPerm); err != nil { @@ -447,7 +451,7 @@ func ValidateAgentMTLSConfig(cfg *Config) error { } if !managerUsesTLSInternal(cfg) { - return fmt.Errorf("EDGE_MTLS_MODE requires MANAGER_API_URL to use https") + return errors.New("EDGE_MTLS_MODE requires MANAGER_API_URL to use https") } _, err := buildManagerClientTLSConfigInternal(cfg) @@ -480,7 +484,7 @@ func ValidateManagerMTLSConfig(cfg *Config) error { func loadCertPoolInternal(caFile string) (*x509.CertPool, error) { caFile = strings.TrimSpace(caFile) if caFile == "" { - return nil, fmt.Errorf("CA file is required") + return nil, errors.New("CA file is required") } pemBytes, err := os.ReadFile(caFile) @@ -490,7 +494,7 @@ func loadCertPoolInternal(caFile string) (*x509.CertPool, error) { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pemBytes) { - return nil, fmt.Errorf("failed to parse PEM certificates") + return nil, errors.New("failed to parse PEM certificates") } return pool, nil @@ -513,7 +517,7 @@ func loadSystemOrCustomCertPoolInternal(caFile string) (*x509.CertPool, error) { return nil, err } if !pool.AppendCertsFromPEM(pemBytes) { - return nil, fmt.Errorf("failed to parse PEM certificates") + return nil, errors.New("failed to parse PEM certificates") } return pool, nil } @@ -620,11 +624,11 @@ func verifiedPeerCertificateEnvironmentIDMatchesInternal(state *tls.ConnectionSt } expectedPath := expectedEdgeMTLSURIPathInternal(envID) if expectedPath == "" { - return fmt.Errorf("environment ID is required for edge mTLS certificate identity check") + return errors.New("environment ID is required for edge mTLS certificate identity check") } trustDomain = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(trustDomain, "."))) if trustDomain == "" { - return fmt.Errorf("edge mTLS trust domain is required for certificate identity check") + return errors.New("edge mTLS trust domain is required for certificate identity check") } leaf := state.VerifiedChains[0][0] for _, uri := range leaf.URIs { @@ -662,7 +666,7 @@ func expectedEdgeMTLSURIPathInternal(envID string) string { func edgeMTLSAssetsDirInternal(cfg *Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("edge config is required") + return "", errors.New("edge config is required") } if configured := strings.TrimSpace(cfg.EdgeMTLSAssetsDir); configured != "" { @@ -683,7 +687,7 @@ func edgeMTLSAssetsDirInternal(cfg *Config) (string, error) { func edgeAgentMTLSAssetsDirInternal(cfg *Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("edge config is required") + return "", errors.New("edge config is required") } if configured := strings.TrimSpace(cfg.EdgeMTLSAssetsDir); configured != "" { return configured, nil @@ -777,7 +781,7 @@ func ensureClientCertificateInternal(ctx context.Context, assetsDir string, envI caCertBlock, _ := pem.Decode(caCertPEM) if caCertBlock == nil { - return "", "", false, fmt.Errorf("failed to parse CA certificate PEM") + return "", "", false, errors.New("failed to parse CA certificate PEM") } caCert, err := x509.ParseCertificate(caCertBlock.Bytes) if err != nil { @@ -786,7 +790,7 @@ func ensureClientCertificateInternal(ctx context.Context, assetsDir string, envI caKeyBlock, _ := pem.Decode(caKeyPEM) if caKeyBlock == nil { - return "", "", false, fmt.Errorf("failed to parse CA private key PEM") + return "", "", false, errors.New("failed to parse CA private key PEM") } caKey, err := x509.ParseECPrivateKey(caKeyBlock.Bytes) if err != nil { @@ -904,11 +908,11 @@ func validateGeneratedCAInternal(certPath, keyPath string) error { return err } if !cert.IsCA { - return fmt.Errorf("generated CA certificate is not a CA") + return errors.New("generated CA certificate is not a CA") } publicKey, ok := cert.PublicKey.(*ecdsa.PublicKey) if !ok || publicKey.Curve != elliptic.P384() { - return fmt.Errorf("generated CA certificate is not ECDSA P-384") + return errors.New("generated CA certificate is not ECDSA P-384") } keyPEM, err := readCAKeyPEMInternal(keyPath) if err != nil { @@ -923,7 +927,7 @@ func validateGeneratedCAInternal(certPath, keyPath string) error { return fmt.Errorf("failed to parse CA private key %s: %w", keyPath, err) } if privateKey.Curve != elliptic.P384() { - return fmt.Errorf("generated CA private key is not ECDSA P-384") + return errors.New("generated CA private key is not ECDSA P-384") } if err := validateCertificateKeyPairInternal(cert, privateKey, "generated CA"); err != nil { return err @@ -938,7 +942,7 @@ func validateGeneratedClientCertificateInternal(certPath, keyPath string, expect } now := time.Now() if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) { - return fmt.Errorf("generated client certificate is not currently valid") + return errors.New("generated client certificate is not currently valid") } if strings.TrimSpace(expectedCommonName) != "" && cert.Subject.CommonName != expectedCommonName { return fmt.Errorf("generated client certificate common name %q does not match expected %q", cert.Subject.CommonName, expectedCommonName) @@ -948,14 +952,14 @@ func validateGeneratedClientCertificateInternal(certPath, keyPath string, expect } publicKey, ok := cert.PublicKey.(*ecdsa.PublicKey) if !ok || publicKey.Curve != elliptic.P384() { - return fmt.Errorf("generated client certificate is not ECDSA P-384") + return errors.New("generated client certificate is not ECDSA P-384") } privateKey, err := readECPrivateKeyInternal(keyPath) if err != nil { return err } if privateKey.Curve != elliptic.P384() { - return fmt.Errorf("generated client private key is not ECDSA P-384") + return errors.New("generated client private key is not ECDSA P-384") } if err := validateCertificateKeyPairInternal(cert, privateKey, "generated client"); err != nil { return err @@ -999,13 +1003,13 @@ func agentMTLSAssetsNeedEnrollmentInternal(certPath string, keyPath string, now now = time.Now() } if now.Before(cert.NotBefore) { - return true, fmt.Sprintf("certificate is not valid before %s", cert.NotBefore.UTC().Format(time.RFC3339)) + return true, "certificate is not valid before " + cert.NotBefore.UTC().Format(time.RFC3339) } if !now.Before(cert.NotAfter) { - return true, fmt.Sprintf("certificate expired at %s", cert.NotAfter.UTC().Format(time.RFC3339)) + return true, "certificate expired at " + cert.NotAfter.UTC().Format(time.RFC3339) } if now.Add(agentMTLSRenewBefore).After(cert.NotAfter) { - return true, fmt.Sprintf("certificate expires soon at %s", cert.NotAfter.UTC().Format(time.RFC3339)) + return true, "certificate expires soon at " + cert.NotAfter.UTC().Format(time.RFC3339) } return false, "" } @@ -1082,7 +1086,10 @@ func lockEdgeMTLSPathInternal(ctx context.Context, dir string, lockName string) lockPath := filepath.Join(absDir, lockName) muValue, _ := managerCALocks.LoadOrStore(lockPath, &sync.Mutex{}) - mu := muValue.(*sync.Mutex) + mu, ok := muValue.(*sync.Mutex) + if !ok { + return nil, &common.ManagerCALockTypeError{} + } deadline := time.Now().Add(managerCALockTimeout) for { @@ -1158,14 +1165,14 @@ func readEdgeMTLSLockInfoInternal(lockPath string) (*edgeMTLSLockInfo, error) { } fields := strings.Fields(string(content)) if len(fields) == 0 { - return nil, fmt.Errorf("edge mTLS lock does not contain a PID") + return nil, errors.New("edge mTLS lock does not contain a PID") } pid, err := strconv.Atoi(fields[0]) if err != nil { return nil, fmt.Errorf("parse edge mTLS lock PID: %w", err) } if pid <= 0 { - return nil, fmt.Errorf("edge mTLS lock PID must be positive") + return nil, errors.New("edge mTLS lock PID must be positive") } info := &edgeMTLSLockInfo{pid: pid} if len(fields) > 1 { @@ -1213,7 +1220,7 @@ var caKeyEncryptInternal = libcrypto.Encrypt func writeCAKeyFileInternal(path string, derBytes []byte) error { pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: derBytes}) if pemBytes == nil { - return fmt.Errorf("failed to encode CA private key to PEM") + return errors.New("failed to encode CA private key to PEM") } ciphertext, err := caKeyEncryptInternal(string(pemBytes)) @@ -1221,7 +1228,7 @@ func writeCAKeyFileInternal(path string, derBytes []byte) error { return fmt.Errorf("failed to encrypt edge mTLS CA private key: %w", err) } if ciphertext == "" { - return fmt.Errorf("failed to encrypt edge mTLS CA private key: encrypted payload is empty") + return errors.New("failed to encrypt edge mTLS CA private key: encrypted payload is empty") } return writeFileAtomicInternal(path, []byte(caKeyEncryptedPrefix+ciphertext), 0o600) diff --git a/backend/pkg/libarcane/edge/tunnel.go b/backend/pkg/libarcane/edge/tunnel.go index f96aa9adda..b4523f7f09 100644 --- a/backend/pkg/libarcane/edge/tunnel.go +++ b/backend/pkg/libarcane/edge/tunnel.go @@ -219,13 +219,13 @@ func (t *TunnelConn) SendRequest(ctx context.Context, msg *TunnelMessage, pendin } type grpcManagerStream interface { - Send(*tunnelpb.ManagerMessage) error + Send(msg *tunnelpb.ManagerMessage) error Recv() (*tunnelpb.AgentMessage, error) Context() context.Context } type grpcAgentStream interface { - Send(*tunnelpb.AgentMessage) error + Send(msg *tunnelpb.AgentMessage) error Recv() (*tunnelpb.ManagerMessage, error) Context() context.Context CloseSend() error @@ -418,7 +418,9 @@ func (t *GRPCAgentTunnelConn) markClosed() { t.closedMu.Unlock() } -func sendRequestWithPending(ctx context.Context, conn interface{ Send(*TunnelMessage) error }, msg *TunnelMessage, pending *sync.Map) (*TunnelMessage, error) { +func sendRequestWithPending(ctx context.Context, conn interface { + Send(msg *TunnelMessage) error +}, msg *TunnelMessage, pending *sync.Map) (*TunnelMessage, error) { ctx, cancel := ensureSendRequestContextInternal(ctx) defer cancel() @@ -476,14 +478,14 @@ func (s *cancelableGRPCManagerStream) Context() context.Context { func ensureSendRequestContextInternal(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { - return context.WithTimeout(context.Background(), defaultSendRequestTimeout) //nolint:gosec // helper intentionally returns the cancel func to callers. + return context.WithTimeout(context.Background(), defaultSendRequestTimeout) } if _, hasDeadline := ctx.Deadline(); hasDeadline { return ctx, func() {} } - return context.WithTimeout(ctx, defaultSendRequestTimeout) //nolint:gosec // helper intentionally returns the cancel func to callers. + return context.WithTimeout(ctx, defaultSendRequestTimeout) } func isExpectedGRPCReceiveErrorInternal(err error) bool { diff --git a/backend/pkg/libarcane/edge/tunnel_proto_adapter.go b/backend/pkg/libarcane/edge/tunnel_proto_adapter.go index 55a668b68c..16cefebf5b 100644 --- a/backend/pkg/libarcane/edge/tunnel_proto_adapter.go +++ b/backend/pkg/libarcane/edge/tunnel_proto_adapter.go @@ -1,6 +1,7 @@ package edge import ( + "errors" "fmt" "maps" "math" @@ -10,7 +11,7 @@ import ( func tunnelMessageToManagerProto(msg *TunnelMessage) (*tunnelpb.ManagerMessage, error) { if msg == nil { - return nil, fmt.Errorf("message is nil") + return nil, errors.New("message is nil") } switch msg.Type { @@ -119,7 +120,7 @@ func tunnelMessageToManagerProto(msg *TunnelMessage) (*tunnelpb.ManagerMessage, func managerProtoToTunnelMessage(msg *tunnelpb.ManagerMessage) (*TunnelMessage, error) { if msg == nil { - return nil, fmt.Errorf("manager message is nil") + return nil, errors.New("manager message is nil") } switch payload := msg.GetPayload().(type) { @@ -214,7 +215,7 @@ func managerProtoToTunnelMessage(msg *tunnelpb.ManagerMessage) (*TunnelMessage, func tunnelMessageToAgentProto(msg *TunnelMessage) (*tunnelpb.AgentMessage, error) { if msg == nil { - return nil, fmt.Errorf("message is nil") + return nil, errors.New("message is nil") } switch msg.Type { @@ -328,7 +329,7 @@ func tunnelMessageToAgentProto(msg *TunnelMessage) (*tunnelpb.AgentMessage, erro func agentProtoToTunnelMessage(msg *tunnelpb.AgentMessage) (*TunnelMessage, error) { if msg == nil { - return nil, fmt.Errorf("agent message is nil") + return nil, errors.New("agent message is nil") } switch payload := msg.GetPayload().(type) { diff --git a/backend/pkg/libarcane/imageupdate/digest.go b/backend/pkg/libarcane/imageupdate/digest.go index 7e8b21a354..46d3cfb6da 100644 --- a/backend/pkg/libarcane/imageupdate/digest.go +++ b/backend/pkg/libarcane/imageupdate/digest.go @@ -2,6 +2,7 @@ package imageupdate import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -83,7 +84,7 @@ func (c *DigestChecker) CheckImageNeedsUpdate(ctx context.Context, imageRef stri result.LocalDigest = localDigest if c.digestResolver == nil { - result.Error = fmt.Errorf("remote digest resolver unavailable") + result.Error = errors.New("remote digest resolver unavailable") return result } @@ -130,7 +131,7 @@ func (c *DigestChecker) getLocalDigestInternal(ctx context.Context, imageRef str return inspect.ID, nil } - return "", fmt.Errorf("no digest available for image") + return "", errors.New("no digest available for image") } // CompareWithPulled compares the current container's image with a freshly pulled image diff --git a/backend/pkg/libarcane/imageupdate/labels.go b/backend/pkg/libarcane/imageupdate/labels.go index fe0d28794c..68e020c6cf 100644 --- a/backend/pkg/libarcane/imageupdate/labels.go +++ b/backend/pkg/libarcane/imageupdate/labels.go @@ -3,12 +3,12 @@ package imageupdate import "strings" const ( - // Core labels + // LabelArcane and the constants below are the core Arcane container labels. LabelArcane = "com.getarcaneapp.arcane" // Identifies the Arcane container itself LabelArcaneAgent = "com.getarcaneapp.arcane.agent" // Identifies an Arcane agent container LabelUpdater = "com.getarcaneapp.arcane.updater" // Enable/disable updates (true/false) - // Dependency labels + // LabelDependsOn and the constants below are update-dependency labels. LabelDependsOn = "com.getarcaneapp.arcane.depends-on" // Comma-separated list of container names this depends on LabelStopSignal = "com.getarcaneapp.arcane.stop-signal" // Custom stop signal (e.g., SIGINT) ) diff --git a/backend/pkg/libarcane/imageupdate/logfile.go b/backend/pkg/libarcane/imageupdate/logfile.go index 8984d52334..7d322f58ab 100644 --- a/backend/pkg/libarcane/imageupdate/logfile.go +++ b/backend/pkg/libarcane/imageupdate/logfile.go @@ -2,6 +2,7 @@ package imageupdate import ( "context" + "errors" "fmt" "io" "log/slog" @@ -130,11 +131,11 @@ func formatSlogValueInternal(v slog.Value) string { // SetupMessageOnlyLogFile configures slog to write structured logs to stdout and a // message-only format to a file under dataDir. // -// The file format is: key=value key=value ... (no time/level/source) to keep +// The file format is: key=value ... (no time/level/source) to keep // upgrade logs concise for end users. func SetupMessageOnlyLogFile(dataDir string, filePrefix string, minLevel slog.Level) (*os.File, error) { if strings.TrimSpace(dataDir) == "" { - return nil, fmt.Errorf("dataDir is required") + return nil, errors.New("dataDir is required") } if strings.TrimSpace(filePrefix) == "" { filePrefix = "arcane-upgrade" diff --git a/backend/pkg/libarcane/imageupdate/registry_http.go b/backend/pkg/libarcane/imageupdate/registry_http.go index 5fdddeb562..8414c2587e 100644 --- a/backend/pkg/libarcane/imageupdate/registry_http.go +++ b/backend/pkg/libarcane/imageupdate/registry_http.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -197,7 +198,7 @@ func FetchDigest(ctx context.Context, registryHost, repository, tag string, cred digest := extractDigestFromHeadersInternal(resp.Header) if digest == "" { - return "", fmt.Errorf("no digest header found in response") + return "", errors.New("no digest header found in response") } return digest, nil @@ -206,7 +207,7 @@ func FetchDigest(ctx context.Context, registryHost, repository, tag string, cred func fetchRateLimitWithTokenAuthInternal(ctx context.Context, httpClient *http.Client, registryHost, repository, tag, challenge string, credential *Credentials) (*RateLimitInfo, error) { realm, service := parseWWWAuthInternal(challenge) if realm == "" { - return nil, fmt.Errorf("no auth realm found") + return nil, errors.New("no auth realm found") } if err := validateAuthRealmInternal(registryHost, realm); err != nil { return nil, err @@ -233,7 +234,7 @@ func fetchRateLimitWithTokenAuthInternal(ctx context.Context, httpClient *http.C func fetchWithTokenAuthInternal(ctx context.Context, httpClient *http.Client, registryHost, repository, tag, challenge string, credential *Credentials) (string, error) { realm, service := parseWWWAuthInternal(challenge) if realm == "" { - return "", fmt.Errorf("no auth realm found") + return "", errors.New("no auth realm found") } if err := validateAuthRealmInternal(registryHost, realm); err != nil { return "", err @@ -256,7 +257,7 @@ func fetchWithTokenAuthInternal(ctx context.Context, httpClient *http.Client, re digest := extractDigestFromHeadersInternal(resp.Header) if digest == "" { - return "", fmt.Errorf("no digest header found in authenticated response") + return "", errors.New("no digest header found in authenticated response") } return digest, nil @@ -287,7 +288,7 @@ func fetchRegistryTokenInternal(ctx context.Context, httpClient *http.Client, au req.SetBasicAuth(strings.TrimSpace(credential.Username), strings.TrimSpace(credential.Token)) } - resp, err := httpClient.Do(req) //nolint:gosec // authURL comes from the registry challenge for the current image + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("token request failed: %w", err) } @@ -310,7 +311,7 @@ func fetchRegistryTokenInternal(ctx context.Context, httpClient *http.Client, au token = strings.TrimSpace(tokenResponse.Legacy) } if token == "" { - return "", fmt.Errorf("no token in response") + return "", errors.New("no token in response") } return token, nil @@ -325,7 +326,7 @@ func manifestRequestInternal(ctx context.Context, httpClient *http.Client, regis } addManifestRequestHeadersInternal(req, authHeader) - resp, err := httpClient.Do(req) //nolint:gosec // manifestURL is derived from the normalized image reference + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("manifest request failed: %w", err) } @@ -341,7 +342,7 @@ func manifestRequestInternal(ctx context.Context, httpClient *http.Client, regis } addManifestRequestHeadersInternal(getReq, authHeader) - getResp, err := httpClient.Do(getReq) //nolint:gosec // manifestURL is derived from the normalized image reference + getResp, err := httpClient.Do(getReq) if err != nil { return nil, fmt.Errorf("manifest fallback request failed: %w", err) } @@ -424,18 +425,18 @@ func extractDigestFromHeadersInternal(headers http.Header) string { } func extractRateLimitFromHeadersInternal(headers http.Header) (*RateLimitInfo, error) { - limit, limitWindow, limitErr := parseRateLimitHeaderInternal(headers.Get("RateLimit-Limit")) + limit, limitWindow, limitErr := parseRateLimitHeaderInternal(headers.Get("Ratelimit-Limit")) if limitErr != nil { return nil, fmt.Errorf("parse RateLimit-Limit: %w", limitErr) } - remaining, remainingWindow, remainingErr := parseRateLimitHeaderInternal(headers.Get("RateLimit-Remaining")) + remaining, remainingWindow, remainingErr := parseRateLimitHeaderInternal(headers.Get("Ratelimit-Remaining")) if remainingErr != nil { return nil, fmt.Errorf("parse RateLimit-Remaining: %w", remainingErr) } if limit == nil && remaining == nil { - return nil, fmt.Errorf("rate limit headers not returned") + return nil, errors.New("rate limit headers not returned") } windowSeconds := limitWindow @@ -453,7 +454,7 @@ func extractRateLimitFromHeadersInternal(headers http.Header) (*RateLimitInfo, e Remaining: remaining, Used: used, WindowSeconds: windowSeconds, - Source: strings.TrimSpace(headers.Get("Docker-RateLimit-Source")), + Source: strings.TrimSpace(headers.Get("Docker-Ratelimit-Source")), }, nil } @@ -493,7 +494,7 @@ func parseRateLimitHeaderInternal(value string) (*int, *int, error) { func parsePositiveIntInternal(value string) (*int, error) { if value == "" { - return nil, fmt.Errorf("empty integer value") + return nil, errors.New("empty integer value") } parsed, err := strconv.Atoi(value) @@ -586,7 +587,7 @@ func validateAuthRealmInternal(registryHost, realm string) error { registry := normalizeAuthRealmHostInternal(registryHost) if realmHost == "" { - return fmt.Errorf("invalid auth realm host") + return errors.New("invalid auth realm host") } if realmHost == registry { return nil diff --git a/backend/pkg/libarcane/inspect_compat.go b/backend/pkg/libarcane/inspect_compat.go index d0715e403e..70a06172aa 100644 --- a/backend/pkg/libarcane/inspect_compat.go +++ b/backend/pkg/libarcane/inspect_compat.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -55,7 +56,7 @@ func (c *inspectCompatibilityClient) NetworkList(ctx context.Context, options cl // JSON when the primary typed decode fails on a ParseAddr-style CIDR issue. func ContainerInspectWithCompatibility(ctx context.Context, apiClient client.APIClient, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) { if apiClient == nil { - return client.ContainerInspectResult{}, fmt.Errorf("docker api client is nil") + return client.ContainerInspectResult{}, errors.New("docker api client is nil") } result, err := apiClient.ContainerInspect(ctx, containerID, options) @@ -93,7 +94,7 @@ func ContainerInspectWithCompatibility(ctx context.Context, apiClient client.API // JSON when the primary typed decode fails on a ParseAddr-style CIDR issue. func NetworkInspectWithCompatibility(ctx context.Context, apiClient client.APIClient, networkID string, options client.NetworkInspectOptions) (client.NetworkInspectResult, error) { if apiClient == nil { - return client.NetworkInspectResult{}, fmt.Errorf("docker api client is nil") + return client.NetworkInspectResult{}, errors.New("docker api client is nil") } result, err := apiClient.NetworkInspect(ctx, networkID, options) @@ -134,7 +135,7 @@ func NetworkInspectWithCompatibility(ctx context.Context, apiClient client.APICl // when the primary typed decode fails on a ParseAddr-style CIDR issue. func NetworkListWithCompatibility(ctx context.Context, apiClient client.APIClient, options client.NetworkListOptions) (client.NetworkListResult, error) { if apiClient == nil { - return client.NetworkListResult{}, fmt.Errorf("docker api client is nil") + return client.NetworkListResult{}, errors.New("docker api client is nil") } result, err := apiClient.NetworkList(ctx, options) @@ -170,7 +171,7 @@ func NetworkListWithCompatibility(ctx context.Context, apiClient client.APIClien func fetchDockerAPIJSONInternal(ctx context.Context, apiClient client.APIClient, resourcePath string, query url.Values) ([]byte, error) { dialer := apiClient.Dialer() if dialer == nil { - return nil, fmt.Errorf("docker api client does not expose a dialer") + return nil, errors.New("docker api client does not expose a dialer") } reqURL := &url.URL{ diff --git a/backend/pkg/libarcane/internal_resource.go b/backend/pkg/libarcane/internal_resource.go index 89b4014406..a2325209d5 100644 --- a/backend/pkg/libarcane/internal_resource.go +++ b/backend/pkg/libarcane/internal_resource.go @@ -2,7 +2,7 @@ package libarcane import "strings" -// Internal containers indicate containers used for arcanes utilties, ie: temp containers used for viewing files for volumes etc +// InternalResourceLabel marks containers used for Arcane utilities, e.g. temp containers used for viewing volume files. const InternalResourceLabel = "com.getarcaneapp.internal.resource" func IsInternalContainer(labels map[string]string) bool { diff --git a/backend/pkg/libarcane/libbuild/build_context.go b/backend/pkg/libarcane/libbuild/build_context.go index f981665948..a4aae4d175 100644 --- a/backend/pkg/libarcane/libbuild/build_context.go +++ b/backend/pkg/libarcane/libbuild/build_context.go @@ -1,7 +1,7 @@ package libbuild import ( - "fmt" + "errors" "net/url" "path" "strings" @@ -37,13 +37,13 @@ func ParseGitBuildContextSource(raw string) (*GitBuildContextSource, bool, error fragment = strings.TrimSpace(fragment) if fragment == "" { - return nil, true, fmt.Errorf("git build context fragment cannot be empty") + return nil, true, errors.New("git build context fragment cannot be empty") } ref, subdir, hasSubdir := strings.Cut(fragment, ":") ref = strings.TrimSpace(ref) if ref == "" { - return nil, true, fmt.Errorf("git build context ref cannot be empty") + return nil, true, errors.New("git build context ref cannot be empty") } source.Ref = ref @@ -53,15 +53,15 @@ func ParseGitBuildContextSource(raw string) (*GitBuildContextSource, bool, error subdir = strings.TrimSpace(subdir) if subdir == "" { - return nil, true, fmt.Errorf("git build context subdir cannot be empty") + return nil, true, errors.New("git build context subdir cannot be empty") } if strings.HasPrefix(subdir, "/") { - return nil, true, fmt.Errorf("git build context subdir must be relative") + return nil, true, errors.New("git build context subdir must be relative") } cleanSubdir := path.Clean(subdir) if cleanSubdir == "." || cleanSubdir == ".." || strings.HasPrefix(cleanSubdir, "../") { - return nil, true, fmt.Errorf("git build context subdir must stay within the repository") + return nil, true, errors.New("git build context subdir must stay within the repository") } source.Subdir = cleanSubdir diff --git a/backend/pkg/libarcane/libbuild/builder_buildkit.go b/backend/pkg/libarcane/libbuild/builder_buildkit.go index 87ff21d2ec..ccb57ba909 100644 --- a/backend/pkg/libarcane/libbuild/builder_buildkit.go +++ b/backend/pkg/libarcane/libbuild/builder_buildkit.go @@ -133,14 +133,14 @@ func (b *builder) buildSolveOptInternal(ctx context.Context, req imagetypes.Buil frontendAttrs["platform"] = strings.Join(req.Platforms, ",") } for key, val := range req.BuildArgs { - frontendAttrs[fmt.Sprintf("build-arg:%s", key)] = val + frontendAttrs["build-arg:"+key] = val } for key, val := range req.Labels { k := strings.TrimSpace(key) if k == "" { continue } - frontendAttrs[fmt.Sprintf("label:%s", k)] = val + frontendAttrs["label:"+k] = val } solveOpt := buildkit.SolveOpt{ diff --git a/backend/pkg/libarcane/libbuild/builder_buildkit_local.go b/backend/pkg/libarcane/libbuild/builder_buildkit_local.go index dc2656270e..83af481c71 100644 --- a/backend/pkg/libarcane/libbuild/builder_buildkit_local.go +++ b/backend/pkg/libarcane/libbuild/builder_buildkit_local.go @@ -3,6 +3,7 @@ package libbuild import ( "bufio" "context" + "errors" "fmt" "os" "strings" @@ -15,7 +16,7 @@ import ( func (b *builder) newLocalBuildkitSessionInternal(ctx context.Context) (*buildSession, error) { if b.dockerClientProvider == nil { - return nil, fmt.Errorf("docker service not available") + return nil, errors.New("docker service not available") } dockerClient, err := b.dockerClientProvider.GetClient(ctx) diff --git a/backend/pkg/libarcane/libbuild/builder_docker.go b/backend/pkg/libarcane/libbuild/builder_docker.go index f2855f659f..9718c991a0 100644 --- a/backend/pkg/libarcane/libbuild/builder_docker.go +++ b/backend/pkg/libarcane/libbuild/builder_docker.go @@ -25,6 +25,7 @@ import ( type dockerBuildInput struct { buildFilesystemInput + platform string buildArgs map[string]*string labels map[string]string @@ -140,7 +141,7 @@ func prepareDockerBuildInputInternal(req imagetypes.BuildRequest) (dockerBuildIn } if len(req.Platforms) > 1 { - return dockerBuildInput{}, true, fmt.Errorf("docker build fallback does not support multi-platform builds") + return dockerBuildInput{}, true, errors.New("docker build fallback does not support multi-platform builds") } platform := "" @@ -360,7 +361,7 @@ func (b *builder) pushDockerImagesInternal( writeProgressEventInternal(progressWriter, imagetypes.ProgressEvent{ Type: "build", Service: serviceName, - Status: fmt.Sprintf("pushing %s", tag), + Status: "pushing " + tag, }) pushOptions := dockerclient.ImagePushOptions{} if b.registryAuthProvider != nil { @@ -369,7 +370,7 @@ func (b *builder) pushDockerImagesInternal( writeProgressEventInternal(progressWriter, imagetypes.ProgressEvent{ Type: "build", Service: serviceName, - Status: fmt.Sprintf("registry auth unavailable for %s", tag), + Status: "registry auth unavailable for " + tag, }) } else if authHeader != "" { pushOptions.RegistryAuth = authHeader diff --git a/backend/pkg/libarcane/registryauth/helpers.go b/backend/pkg/libarcane/registryauth/helpers.go index 46ac6f9c9c..bcf4d651fb 100644 --- a/backend/pkg/libarcane/registryauth/helpers.go +++ b/backend/pkg/libarcane/registryauth/helpers.go @@ -95,7 +95,7 @@ func DecodeAuthHeader(authEncoded string) (dockerregistry.AuthConfig, error) { return *cfg, nil } -func RegistryAuthLookupKeys(url string) []string { +func LookupKeys(url string) []string { normalizedHost := NormalizeRegistryForComparison(url) if normalizedHost == "" { return nil diff --git a/backend/pkg/libarcane/registryauth/helpers_test.go b/backend/pkg/libarcane/registryauth/helpers_test.go index eb53641756..771a5ec0db 100644 --- a/backend/pkg/libarcane/registryauth/helpers_test.go +++ b/backend/pkg/libarcane/registryauth/helpers_test.go @@ -81,7 +81,7 @@ func TestDecodeAuthHeader_InvalidInput(t *testing.T) { } func TestRegistryAuthLookupKeys(t *testing.T) { - assert.Equal(t, []string{"ghcr.io"}, RegistryAuthLookupKeys("https://GHCR.IO/")) - assert.Equal(t, []string{"docker.io", "index.docker.io", "registry-1.docker.io"}, RegistryAuthLookupKeys("https://index.docker.io/v1/")) - assert.Nil(t, RegistryAuthLookupKeys(" ")) + assert.Equal(t, []string{"ghcr.io"}, LookupKeys("https://GHCR.IO/")) + assert.Equal(t, []string{"docker.io", "index.docker.io", "registry-1.docker.io"}, LookupKeys("https://index.docker.io/v1/")) + assert.Nil(t, LookupKeys(" ")) } diff --git a/backend/pkg/libarcane/startup/runtime_identity.go b/backend/pkg/libarcane/startup/runtime_identity.go index d0315dbf7d..b148599e6c 100644 --- a/backend/pkg/libarcane/startup/runtime_identity.go +++ b/backend/pkg/libarcane/startup/runtime_identity.go @@ -320,12 +320,12 @@ func ensureSQLiteFilesExistInternal(databaseURL string) error { // prepareWritablePathsInternal is not called. dir := filepath.Dir(sqlitePath) if dir != "" && dir != "." { - if err := os.MkdirAll(dir, pkgutils.DirPerm); err != nil { //nolint:gosec // path is derived from the configured SQLite DSN, not user input + if err := os.MkdirAll(dir, pkgutils.DirPerm); err != nil { return fmt.Errorf("create sqlite directory %s: %w", dir, err) } } - file, err := os.OpenFile(sqlitePath, os.O_CREATE|os.O_RDWR, pkgutils.FilePerm) //nolint:gosec // path is derived from the configured SQLite DSN + file, err := os.OpenFile(sqlitePath, os.O_CREATE|os.O_RDWR, pkgutils.FilePerm) if err != nil { return fmt.Errorf("create sqlite file %s: %w", sqlitePath, err) } @@ -385,7 +385,7 @@ func chownRecursiveInternal(path string, uid int, gid int, mountpoints map[strin } return filepath.SkipDir } - //nolint:gosec // currentPath comes from fixed container paths under /app/data or /builds + return lchownFn(currentPath, uid, gid) }) } diff --git a/backend/pkg/libarcane/swarm/stack_deploy_engine.go b/backend/pkg/libarcane/swarm/stack_deploy_engine.go index 2fd276ae8d..0e480b809f 100644 --- a/backend/pkg/libarcane/swarm/stack_deploy_engine.go +++ b/backend/pkg/libarcane/swarm/stack_deploy_engine.go @@ -188,10 +188,7 @@ func reconcileStackServices( if service.Name == "" { service.Name = key } - spec, err := buildServiceSpec(service, stackName, stackLabels, networkNameByKey, configMetaByKey, secretMetaByKey, project.Volumes) - if err != nil { - return nil, err - } + spec := buildServiceSpec(service, stackName, stackLabels, networkNameByKey, configMetaByKey, secretMetaByKey, project.Volumes) desiredServices[spec.Name] = struct{}{} if existing, ok := existingServices[spec.Name]; ok { @@ -613,7 +610,7 @@ func buildServiceSpec( configMetaByKey map[string]resourceMeta, secretMetaByKey map[string]resourceMeta, projectVolumes composegotypes.Volumes, -) (swarm.ServiceSpec, error) { +) swarm.ServiceSpec { serviceName := stackScopedName(stackName, service.Name) serviceLabels := mergeLabels(nil, stackLabels) if service.Deploy != nil { @@ -668,7 +665,7 @@ func buildServiceSpec( applyDeployConfig(&spec, service.Deploy, service.Scale) applyServicePorts(&spec, service.Ports) - return spec, nil + return spec } func createSwarmService( @@ -1082,8 +1079,8 @@ func convertIPAM(cfg composegotypes.IPAMConfig) *network.IPAM { if pool == nil { continue } - subnet, _ := parsePrefix(pool.Subnet) - ipRange, _ := parsePrefix(pool.IPRange) + subnet := parsePrefix(pool.Subnet) + ipRange := parsePrefix(pool.IPRange) gateway, _ := parseAddr(pool.Gateway) pools = append(pools, network.IPAMConfig{ Subnet: subnet, @@ -1123,16 +1120,16 @@ func parseAddr(value string) (netip.Addr, bool) { return addr, true } -func parsePrefix(value string) (netip.Prefix, bool) { +func parsePrefix(value string) netip.Prefix { value = strings.TrimSpace(value) if value == "" { - return netip.Prefix{}, false + return netip.Prefix{} } prefix, err := netip.ParsePrefix(value) if err != nil { - return netip.Prefix{}, false + return netip.Prefix{} } - return prefix, true + return prefix } func parseAuxAddresses(values map[string]string) map[string]netip.Addr { diff --git a/backend/pkg/libarcane/system/gpu.go b/backend/pkg/libarcane/system/gpu.go index a785391b08..29889fcb63 100644 --- a/backend/pkg/libarcane/system/gpu.go +++ b/backend/pkg/libarcane/system/gpu.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/csv" + "errors" "fmt" "log/slog" "os" @@ -81,7 +82,7 @@ func (m *GPUMonitor) Stats(ctx context.Context) ([]systemtypes.GPUStats, error) t := m.gpuType m.cacheMu.RUnlock() if t == "" { - return nil, fmt.Errorf("no supported GPU found") + return nil, errors.New("no supported GPU found") } return m.statsForTypeInternal(ctx, t) } @@ -95,7 +96,7 @@ func (m *GPUMonitor) statsForTypeInternal(ctx context.Context, gpuType string) ( case "intel": return getIntelStatsInternal(ctx) default: - return nil, fmt.Errorf("no supported GPU found") + return nil, errors.New("no supported GPU found") } } @@ -124,21 +125,21 @@ func (m *GPUMonitor) detectInternal(ctx context.Context) error { slog.InfoContext(ctx, "Using configured GPU type", "type", "nvidia") return nil } - return fmt.Errorf("nvidia-smi not found but GPU_TYPE set to nvidia") + return errors.New("nvidia-smi not found but GPU_TYPE set to nvidia") case "amd": if HasAMDGPU() { m.markDetectedInternal("amd", AMDGPUSysfsPath) slog.InfoContext(ctx, "Using configured GPU type", "type", "amd") return nil } - return fmt.Errorf("AMD GPU not found in sysfs but GPU_TYPE set to amd") + return errors.New("AMD GPU not found in sysfs but GPU_TYPE set to amd") case "intel": if path, err := exec.LookPath("intel_gpu_top"); err == nil { m.markDetectedInternal("intel", path) slog.InfoContext(ctx, "Using configured GPU type", "type", "intel") return nil } - return fmt.Errorf("intel_gpu_top not found but GPU_TYPE set to intel") + return errors.New("intel_gpu_top not found but GPU_TYPE set to intel") default: slog.WarnContext(ctx, "Invalid GPU_TYPE specified, falling back to auto-detection", "gpu_type", t) } @@ -161,7 +162,7 @@ func (m *GPUMonitor) detectInternal(ctx context.Context) error { } m.detectionDone = true - return fmt.Errorf("no supported GPU found") + return errors.New("no supported GPU found") } // HasAMDGPU reports whether a card with mem_info_vram_total exists under AMDGPUSysfsPath. @@ -242,7 +243,7 @@ func getNvidiaStatsInternal(ctx context.Context) ([]systemtypes.GPUStats, error) } if len(stats) == 0 { - return nil, fmt.Errorf("no GPU data parsed from nvidia-smi") + return nil, errors.New("no GPU data parsed from nvidia-smi") } slog.DebugContext(ctx, "Collected NVIDIA GPU stats", "gpu_count", len(stats)) @@ -265,11 +266,11 @@ func getAMDStatsInternal(ctx context.Context) ([]systemtypes.GPUStats, error) { } devicePath := fmt.Sprintf("%s/%s/device", AMDGPUSysfsPath, name) - memTotalBytes, err := readSysfsValueInternal(fmt.Sprintf("%s/mem_info_vram_total", devicePath)) + memTotalBytes, err := readSysfsValueInternal(devicePath + "/mem_info_vram_total") if err != nil { continue } - memUsedBytes, err := readSysfsValueInternal(fmt.Sprintf("%s/mem_info_vram_used", devicePath)) + memUsedBytes, err := readSysfsValueInternal(devicePath + "/mem_info_vram_used") if err != nil { slog.WarnContext(ctx, "Failed to read AMD GPU memory used", "card", name, "error", err) continue @@ -285,7 +286,7 @@ func getAMDStatsInternal(ctx context.Context) ([]systemtypes.GPUStats, error) { } if len(stats) == 0 { - return nil, fmt.Errorf("no AMD GPU data found in sysfs") + return nil, errors.New("no AMD GPU data found in sysfs") } slog.DebugContext(ctx, "Collected AMD GPU stats", "gpu_count", len(stats)) diff --git a/backend/pkg/libarcane/timeouts/timeouts.go b/backend/pkg/libarcane/timeouts/timeouts.go index 7c9aeaee30..1fc74bbef6 100644 --- a/backend/pkg/libarcane/timeouts/timeouts.go +++ b/backend/pkg/libarcane/timeouts/timeouts.go @@ -23,6 +23,6 @@ func GetDuration(settingSeconds int, defaultDuration time.Duration) time.Duratio return defaultDuration } -func WithTimeout(ctx context.Context, settingSeconds int, defaultDuration time.Duration) (context.Context, context.CancelFunc) { //nolint:gosec // helper intentionally returns the cancel func to callers. - return context.WithTimeout(ctx, GetDuration(settingSeconds, defaultDuration)) //nolint:gosec // helper intentionally returns the cancel func to callers. +func WithTimeout(ctx context.Context, settingSeconds int, defaultDuration time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, GetDuration(settingSeconds, defaultDuration)) } diff --git a/backend/pkg/libarcane/ws/diagnostics.go b/backend/pkg/libarcane/ws/diagnostics.go index 36b717f568..58b5369791 100644 --- a/backend/pkg/libarcane/ws/diagnostics.go +++ b/backend/pkg/libarcane/ws/diagnostics.go @@ -13,6 +13,7 @@ const workerGoroutineCountTTL = 5 * time.Second var workerGoroutineCountCache struct { sync.Mutex + value int at time.Time } diff --git a/backend/pkg/pagination/filters.go b/backend/pkg/pagination/filters.go index 399667bf6f..11379a5857 100644 --- a/backend/pkg/pagination/filters.go +++ b/backend/pkg/pagination/filters.go @@ -27,7 +27,7 @@ func SearchOrderAndPaginate[T any](items []T, params QueryParams, searchConfig C items = sortFunction(items, params.SortParams, searchConfig.SortBindings) totalCount := len(items) - items = paginateItemsFunction(items, params.PaginationParams) + items = paginateItemsFunction(items, params.Params) return FilterResult[T]{ Items: items, diff --git a/backend/pkg/pagination/pagination.go b/backend/pkg/pagination/pagination.go index b61e428deb..c96e087636 100644 --- a/backend/pkg/pagination/pagination.go +++ b/backend/pkg/pagination/pagination.go @@ -1,6 +1,6 @@ package pagination -type PaginationParams struct { +type Params struct { Start int Limit int // SkipCount opts out of the COUNT(*) query that backs TotalItems/TotalPages. @@ -13,7 +13,7 @@ type PaginationParams struct { // UnknownTotal is the sentinel returned in TotalItems/TotalPages when SkipCount is set. const UnknownTotal int64 = -1 -func paginateItemsFunction[T any](items []T, params PaginationParams) []T { +func paginateItemsFunction[T any](items []T, params Params) []T { if params.Limit <= 0 { return items } diff --git a/backend/pkg/pagination/pagination_test.go b/backend/pkg/pagination/pagination_test.go index 6325c9d2fe..cb41a3b826 100644 --- a/backend/pkg/pagination/pagination_test.go +++ b/backend/pkg/pagination/pagination_test.go @@ -21,7 +21,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 10, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: -1, }, @@ -42,7 +42,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 0, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: -1, }, @@ -63,7 +63,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 100, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: 10, }, @@ -84,7 +84,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 100, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 10, Limit: 10, }, @@ -105,7 +105,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 3, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: 0, }, @@ -133,32 +133,32 @@ func TestPaginateItemsFunction(t *testing.T) { tests := []struct { name string - params PaginationParams + params Params expected []int }{ { name: "show all mode (limit = -1)", - params: PaginationParams{Start: 0, Limit: -1}, + params: Params{Start: 0, Limit: -1}, expected: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, }, { name: "show all mode (limit = 0)", - params: PaginationParams{Start: 0, Limit: 0}, + params: Params{Start: 0, Limit: 0}, expected: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, }, { name: "normal pagination first page", - params: PaginationParams{Start: 0, Limit: 3}, + params: Params{Start: 0, Limit: 3}, expected: []int{1, 2, 3}, }, { name: "normal pagination second page", - params: PaginationParams{Start: 3, Limit: 3}, + params: Params{Start: 3, Limit: 3}, expected: []int{4, 5, 6}, }, { name: "pagination at end of list", - params: PaginationParams{Start: 8, Limit: 5}, + params: Params{Start: 8, Limit: 5}, expected: []int{9, 10}, }, } diff --git a/backend/pkg/pagination/params.go b/backend/pkg/pagination/params.go index d38099a381..c0e0aa66a9 100644 --- a/backend/pkg/pagination/params.go +++ b/backend/pkg/pagination/params.go @@ -3,6 +3,7 @@ package pagination type QueryParams struct { SearchQuery SortParams - PaginationParams + Params + Filters map[string]string } diff --git a/backend/pkg/pagination/search.go b/backend/pkg/pagination/search.go index 3b9ba0fa2c..d9a3df59a4 100644 --- a/backend/pkg/pagination/search.go +++ b/backend/pkg/pagination/search.go @@ -4,7 +4,8 @@ import ( "strings" ) -// Return any error to skip the field (for when matching an unknown state on an enum) +// SearchAccessor extracts a searchable string from T. Return any error to skip +// the field (e.g. when matching an unknown enum state). // // Note: returning ("", nil) will match! type SearchAccessor[T any] = func(T) (string, error) diff --git a/backend/pkg/pagination/skipcount_test.go b/backend/pkg/pagination/skipcount_test.go index 7f07b20c85..4aea66bb0a 100644 --- a/backend/pkg/pagination/skipcount_test.go +++ b/backend/pkg/pagination/skipcount_test.go @@ -30,8 +30,8 @@ func TestPaginateAndSortDB_SkipCountReturnsUnknownTotals(t *testing.T) { var got []widget resp, err := PaginateAndSortDB(QueryParams{ - PaginationParams: PaginationParams{Start: 0, Limit: 2, SkipCount: true}, - SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, + Params: Params{Start: 0, Limit: 2, SkipCount: true}, + SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, }, db.Model(&widget{}), &got) require.NoError(t, err) require.Len(t, got, 2) @@ -46,8 +46,8 @@ func TestPaginateAndSortDB_DefaultStillCounts(t *testing.T) { var got []widget resp, err := PaginateAndSortDB(QueryParams{ - PaginationParams: PaginationParams{Start: 0, Limit: 2}, - SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, + Params: Params{Start: 0, Limit: 2}, + SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, }, db.Model(&widget{}), &got) require.NoError(t, err) require.Len(t, got, 2) @@ -60,8 +60,8 @@ func TestPaginateAndSortDB_SkipCountShowAll(t *testing.T) { var got []widget resp, err := PaginateAndSortDB(QueryParams{ - PaginationParams: PaginationParams{Start: 0, Limit: -1, SkipCount: true}, - SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, + Params: Params{Start: 0, Limit: -1, SkipCount: true}, + SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, }, db.Model(&widget{}), &got) require.NoError(t, err) require.Len(t, got, 5) diff --git a/backend/pkg/projects/compose.go b/backend/pkg/projects/compose.go index 2c915d7f1e..13fc84bf98 100644 --- a/backend/pkg/projects/compose.go +++ b/backend/pkg/projects/compose.go @@ -41,6 +41,7 @@ func NewClient(ctx context.Context) (*Client, error) { type inspectCompatibleDockerCli struct { command.Cli + apiClient client.APIClient } diff --git a/backend/pkg/projects/env.go b/backend/pkg/projects/env.go index e1ba25e580..c39f441934 100644 --- a/backend/pkg/projects/env.go +++ b/backend/pkg/projects/env.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -137,7 +138,7 @@ func (l *EnvLoader) loadAndMergeGlobalEnv(ctx context.Context, path string, envM } func (l *EnvLoader) loadAndMergeProjectEnv(ctx context.Context, path string, envMap, injectionVars EnvMap) error { - key := strings.Join([]string{path, l.projectsDir, fmt.Sprint(l.autoInjectEnv), envContextFingerprintInternal(envMap)}, "\x00") + key := strings.Join([]string{path, l.projectsDir, strconv.FormatBool(l.autoInjectEnv), envContextFingerprintInternal(envMap)}, "\x00") entry, err := loadCachedEnvFileInternal(ctx, projectEnvFileCache, key, path, envMap) if err != nil { return err @@ -400,7 +401,7 @@ func parseEnvWithContext(r io.Reader, contextEnv EnvMap) (EnvMap, error) { return nil, fmt.Errorf("parse env: %w", err) } - return EnvMap(envMap), nil + return envMap, nil } func readOptionalProjectFileInternal(projectPath, fileName string) (string, bool, error) { diff --git a/backend/pkg/projects/fs_templates.go b/backend/pkg/projects/fs_templates.go index 50f9eac78a..a7fc6a4f05 100644 --- a/backend/pkg/projects/fs_templates.go +++ b/backend/pkg/projects/fs_templates.go @@ -36,7 +36,7 @@ func ReadFolderComposeTemplate(baseDir, folder string) (string, *string, string, } } - desc := fmt.Sprintf("Imported from %s", composePath) + desc := "Imported from " + composePath return string(b), envPtr, desc, true, nil } diff --git a/backend/pkg/projects/fs_util.go b/backend/pkg/projects/fs_util.go index 7408b7401e..5ce01be287 100644 --- a/backend/pkg/projects/fs_util.go +++ b/backend/pkg/projects/fs_util.go @@ -3,6 +3,7 @@ package projects import ( "bytes" "context" + "errors" "fmt" "log/slog" "os" @@ -458,7 +459,7 @@ func CreateUniqueDir(projectsRoot, basePath, name string, perm os.FileMode) (pat // Reject empty or invalid sanitized names if sanitized == "" || strings.Trim(sanitized, "_") == "" { - return "", "", fmt.Errorf("invalid project name: results in empty directory name") + return "", "", errors.New("invalid project name: results in empty directory name") } // Get absolute path of the true projects root for validation @@ -481,7 +482,7 @@ func CreateUniqueDir(projectsRoot, basePath, name string, perm os.FileMode) (pat // Security check: ensure candidate is a subdirectory of projectsRoot if !IsSafeSubdirectory(projectsRootAbs, candidateAbs) { - return "", "", fmt.Errorf("project directory would be outside allowed projects root") + return "", "", errors.New("project directory would be outside allowed projects root") } if mkErr := os.Mkdir(candidate, perm); mkErr == nil { @@ -493,7 +494,7 @@ func CreateUniqueDir(projectsRoot, basePath, name string, perm os.FileMode) (pat if strings.HasPrefix(candidateAbs, projectsRootAbs+string(filepath.Separator)) { _ = os.Remove(candidateAbs) } - return "", "", fmt.Errorf("created directory is outside allowed projects root") + return "", "", errors.New("created directory is outside allowed projects root") } return candidate, folderName, nil diff --git a/backend/pkg/projects/fs_writer.go b/backend/pkg/projects/fs_writer.go index 391c5914cb..acf7328846 100644 --- a/backend/pkg/projects/fs_writer.go +++ b/backend/pkg/projects/fs_writer.go @@ -55,7 +55,7 @@ func WriteComposeFile(projectsRoot, dirPath, content string) error { rootAbs = filepath.Clean(rootAbs) if !IsSafeSubdirectory(rootAbs, dirPath) { - return fmt.Errorf("refusing to write compose file: path outside projects root") + return errors.New("refusing to write compose file: path outside projects root") } if err := os.MkdirAll(dirPath, pkgutils.DirPerm); err != nil { @@ -92,7 +92,7 @@ func WriteProjectFile(projectsRoot, dirPath, fileName, content string) error { rootAbs = filepath.Clean(rootAbs) if !IsSafeSubdirectory(rootAbs, dirPath) { - return fmt.Errorf("refusing to write project file: path outside projects root") + return errors.New("refusing to write project file: path outside projects root") } if err := os.MkdirAll(dirPath, pkgutils.DirPerm); err != nil { @@ -125,7 +125,7 @@ func RemoveProjectFile(projectsRoot, dirPath, fileName string) error { rootAbs = filepath.Clean(rootAbs) if !IsSafeSubdirectory(rootAbs, dirPath) { - return fmt.Errorf("refusing to remove project file: path outside projects root") + return errors.New("refusing to remove project file: path outside projects root") } if fileName == "" || filepath.Base(fileName) != fileName || strings.Contains(fileName, string(filepath.Separator)) { @@ -234,7 +234,7 @@ func WriteSyncedDirectory(projectsRoot, projectPath string, files []SyncFile) ([ projectAbs = filepath.Clean(projectAbs) if !IsSafeSubdirectory(rootAbs, projectAbs) { - return nil, fmt.Errorf("project path is outside projects root") + return nil, errors.New("project path is outside projects root") } // Ensure project directory exists @@ -298,7 +298,7 @@ func CleanupRemovedFiles(projectsRoot, projectPath string, oldFiles, newFiles [] projectAbs = filepath.Clean(projectAbs) if !IsSafeSubdirectory(rootAbs, projectAbs) { - return fmt.Errorf("project path is outside projects root") + return errors.New("project path is outside projects root") } // Build set of new files for quick lookup diff --git a/backend/pkg/projects/includes.go b/backend/pkg/projects/includes.go index d1bbb6b473..ca4ac04656 100644 --- a/backend/pkg/projects/includes.go +++ b/backend/pkg/projects/includes.go @@ -81,7 +81,7 @@ func ParseIncludesFromContent(composeFilePath string, content []byte, envMap Env // `include:` key present but null (e.g. `include: ~`) — treat as empty. return []IncludeFile{}, nil default: - return nil, fmt.Errorf("invalid include type") + return nil, errors.New("invalid include type") } return includeFiles, nil @@ -111,7 +111,7 @@ func extractIncludePathsInternal(item any) ([]string, error) { case map[string]any: return extractIncludePathsFromMapInternal(v) default: - return nil, fmt.Errorf("invalid include item type") + return nil, errors.New("invalid include item type") } } @@ -137,7 +137,7 @@ func extractIncludePathsFromMapInternal(v map[string]any) ([]string, error) { func resolveIncludeFileInternal(includePath, baseDir string, envMap EnvMap, includeContent bool) (IncludeFile, error) { if includePath == "" { - return IncludeFile{}, fmt.Errorf("empty include path") + return IncludeFile{}, errors.New("empty include path") } // Expand environment variables in the include path (e.g., ${PROJECT_STACK_DIR}) @@ -194,7 +194,7 @@ func readIncludeContentInternal(fullPath, includePath string, includeContent boo // Only allows writing within the project directory func ValidateIncludePathForWrite(projectDir, includePath string) (string, error) { if includePath == "" { - return "", fmt.Errorf("include path cannot be empty") + return "", errors.New("include path cannot be empty") } // Resolve project directory to absolute path and evaluate symlinks @@ -239,7 +239,7 @@ func ValidateIncludePathForWrite(projectDir, includePath string) (string, error) // Prevent targeting the project directory itself if evalPath == absProjectDir { - return "", fmt.Errorf("include path cannot be the project directory itself") + return "", errors.New("include path cannot be the project directory itself") } // Check if resolved path is within project directory @@ -247,7 +247,7 @@ func ValidateIncludePathForWrite(projectDir, includePath string) (string, error) isWithinProject := strings.HasPrefix(evalPath+string(filepath.Separator), projectPrefix) if !isWithinProject { - return "", fmt.Errorf("write access denied: path is outside project directory") + return "", errors.New("write access denied: path is outside project directory") } return absFullPath, nil diff --git a/backend/pkg/remenv/client.go b/backend/pkg/remenv/client.go index 40270b24bf..6e658c59f3 100644 --- a/backend/pkg/remenv/client.go +++ b/backend/pkg/remenv/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "maps" @@ -13,7 +14,7 @@ import ( ) const ( - HeaderAPIKey = "X-API-Key" // #nosec G101: header name, not a credential + HeaderAPIKey = "X-Api-Key" // #nosec G101: header name, not a credential HeaderAgentToken = "X-Arcane-Agent-Token" // #nosec G101: header name, not a credential HeaderAuthorization = "Authorization" bearerScheme = "Bearer " @@ -72,14 +73,14 @@ type TunnelTransportFuncs struct { func (t TunnelTransportFuncs) EnsureAvailable(ctx context.Context, envID string) error { if t.EnsureAvailableFunc == nil { - return fmt.Errorf("edge transport unavailable") + return errors.New("edge transport unavailable") } return t.EnsureAvailableFunc(ctx, envID) } func (t TunnelTransportFuncs) Do(ctx context.Context, envID, method, path string, headers map[string]string, body []byte) (*Response, error) { if t.DoFunc == nil { - return nil, fmt.Errorf("edge transport unavailable") + return nil, errors.New("edge transport unavailable") } return t.DoFunc(ctx, envID, method, path, headers, body) } @@ -123,7 +124,7 @@ func (c *Client) DoJSON(ctx context.Context, req Request, out any) error { func (c *Client) doViaTunnelInternal(ctx context.Context, req Request) (*Response, error) { if c.tunnel == nil { - return nil, &TransportError{Err: fmt.Errorf("edge transport unavailable")} + return nil, &TransportError{Err: errors.New("edge transport unavailable")} } if err := c.tunnel.EnsureAvailable(ctx, req.EnvironmentID); err != nil { @@ -153,7 +154,7 @@ func (c *Client) doDirectHTTPInternal(ctx context.Context, req Request) (*Respon httpReq.Header.Set(key, value) } - resp, err := c.httpClient.Do(httpReq) //nolint:gosec // intentional request to configured remote environment URL + resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, &TransportError{Err: err} } @@ -191,7 +192,7 @@ func (r *Response) DecodeJSON(out any) error { return nil } if r == nil { - return &DecodeError{Err: fmt.Errorf("response is nil")} + return &DecodeError{Err: errors.New("response is nil")} } if err := json.Unmarshal(r.Body, out); err != nil { return &DecodeError{Err: err} diff --git a/backend/pkg/scheduler/analytics_job.go b/backend/pkg/scheduler/analytics_job.go index 08cc3999dd..061e752e4d 100644 --- a/backend/pkg/scheduler/analytics_job.go +++ b/backend/pkg/scheduler/analytics_job.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -133,7 +134,7 @@ func (j *AnalyticsJob) Run(ctx context.Context) { } req.Header.Set("Content-Type", "application/json") - resp, err := j.httpClient.Do(req) //nolint:gosec // intentional request to configured analytics heartbeat endpoint + resp, err := j.httpClient.Do(req) if err != nil { return struct{}{}, fmt.Errorf("failed to send request: %w", err) } @@ -193,7 +194,7 @@ func (j *AnalyticsJob) Reschedule(ctx context.Context) error { func (j *AnalyticsJob) claimHeartbeatAttemptWindowInternal(ctx context.Context) (bool, error) { if j.kvService == nil { - return false, fmt.Errorf("analytics heartbeat kv service is not configured") + return false, errors.New("analytics heartbeat kv service is not configured") } j.runMu.Lock() diff --git a/backend/pkg/utils/auth.go b/backend/pkg/utils/auth.go index afed778466..ea01e29df7 100644 --- a/backend/pkg/utils/auth.go +++ b/backend/pkg/utils/auth.go @@ -6,6 +6,6 @@ package utils const ( HeaderAgentBootstrap = "X-Arcane-Agent-Bootstrap" HeaderAgentToken = "X-Arcane-Agent-Token" // #nosec G101: header name, not a credential - HeaderApiKey = "X-API-Key" // #nosec G101: header name, not a credential + HeaderApiKey = "X-Api-Key" // #nosec G101: header name, not a credential AgentPairingPrefix = "/api/environments/0/agent/pair" ) diff --git a/backend/pkg/utils/cache/cache_util.go b/backend/pkg/utils/cache/cache_util.go index f391a33cad..edf28fcd30 100644 --- a/backend/pkg/utils/cache/cache_util.go +++ b/backend/pkg/utils/cache/cache_util.go @@ -9,12 +9,12 @@ import ( "golang.org/x/sync/singleflight" ) -type ErrStale struct { +type StaleError struct { Err error } -func (e *ErrStale) Error() string { return "stale cache value: " + e.Err.Error() } -func (e *ErrStale) Unwrap() error { return e.Err } +func (e *StaleError) Error() string { return "stale cache value: " + e.Err.Error() } +func (e *StaleError) Unwrap() error { return e.Err } type Cache[T any] struct { ttl time.Duration @@ -125,7 +125,7 @@ func (c *Cache[T]) GetOrFetch(ctx context.Context, fetch func(ctx context.Contex }) if err != nil { if hasStale { - return stale, &ErrStale{Err: err} + return stale, &StaleError{Err: err} } var zero T return zero, err diff --git a/backend/pkg/utils/httpx/outbound_url.go b/backend/pkg/utils/httpx/outbound_url.go index cec77efd7f..654bb51859 100644 --- a/backend/pkg/utils/httpx/outbound_url.go +++ b/backend/pkg/utils/httpx/outbound_url.go @@ -1,6 +1,7 @@ package httpx import ( + "errors" "fmt" "net/url" "strings" @@ -13,7 +14,7 @@ import ( func ValidateOutboundHTTPURL(rawURL string) (*url.URL, error) { trimmed := strings.TrimSpace(rawURL) if trimmed == "" { - return nil, fmt.Errorf("URL is required") + return nil, errors.New("URL is required") } parsed, err := url.Parse(trimmed) @@ -27,11 +28,11 @@ func ValidateOutboundHTTPURL(rawURL string) (*url.URL, error) { } if parsed.User != nil { - return nil, fmt.Errorf("embedded credentials are not allowed") + return nil, errors.New("embedded credentials are not allowed") } if parsed.Host == "" || parsed.Hostname() == "" { - return nil, fmt.Errorf("URL host is required") + return nil, errors.New("URL host is required") } return parsed, nil diff --git a/backend/pkg/utils/httpx/safe_remote.go b/backend/pkg/utils/httpx/safe_remote.go index b73acf32fa..079889f583 100644 --- a/backend/pkg/utils/httpx/safe_remote.go +++ b/backend/pkg/utils/httpx/safe_remote.go @@ -126,7 +126,7 @@ func NewSafeOutboundHTTPClient(base *http.Client, lookupIP LookupIPFunc) (*http. if lastErr != nil { return nil, lastErr } - return nil, fmt.Errorf("failed to resolve remote host") + return nil, errors.New("failed to resolve remote host") } client := *base @@ -150,7 +150,11 @@ func NewSafeOutboundHTTPClient(base *http.Client, lookupIP LookupIPFunc) (*http. func cloneHTTPTransportInternal(base http.RoundTripper) (*http.Transport, error) { switch t := base.(type) { case nil: - return http.DefaultTransport.(*http.Transport).Clone(), nil + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, &common.DefaultTransportTypeError{} + } + return defaultTransport.Clone(), nil case *http.Transport: return t.Clone(), nil default: diff --git a/backend/pkg/utils/jwtclaims/jwt.go b/backend/pkg/utils/jwtclaims/jwt.go index 493c9c970f..e14832103e 100644 --- a/backend/pkg/utils/jwtclaims/jwt.go +++ b/backend/pkg/utils/jwtclaims/jwt.go @@ -85,15 +85,12 @@ func GetStringSliceClaim(m map[string]any, key string) []string { // CheckOrGenerateJwtSecret verifies a secret exists or generates a random one func CheckOrGenerateJwtSecret(jwtSecret string) []byte { - var secretBytes []byte if jwtSecret != "" { - secretBytes = []byte(jwtSecret) - return secretBytes - } else { - secretBytes = make([]byte, 32) - if _, err := rand.Read(secretBytes); err != nil { - panic(fmt.Errorf("failed to generate random JWT secret: %w", err)) - } + return []byte(jwtSecret) + } + secretBytes := make([]byte, 32) + if _, err := rand.Read(secretBytes); err != nil { + panic(fmt.Errorf("failed to generate random JWT secret: %w", err)) } return secretBytes } diff --git a/backend/pkg/utils/notifications/discord_sender.go b/backend/pkg/utils/notifications/discord_sender.go index cb201a5ad8..97d7024eb9 100644 --- a/backend/pkg/utils/notifications/discord_sender.go +++ b/backend/pkg/utils/notifications/discord_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -25,10 +26,10 @@ func BuildDiscordURL(config models.DiscordConfig) (string, error) { // SendDiscord sends a message via Shoutrrr Discord using proper service configuration func SendDiscord(ctx context.Context, config models.DiscordConfig, message string) error { if config.WebhookID == "" { - return fmt.Errorf("discord webhook ID is empty") + return errors.New("discord webhook ID is empty") } if config.Token == "" { - return fmt.Errorf("discord token is empty") + return errors.New("discord token is empty") } shoutrrrURL, err := BuildDiscordURL(config) diff --git a/backend/pkg/utils/notifications/email_sender.go b/backend/pkg/utils/notifications/email_sender.go index 6414200a99..d18597d09e 100644 --- a/backend/pkg/utils/notifications/email_sender.go +++ b/backend/pkg/utils/notifications/email_sender.go @@ -3,6 +3,7 @@ package notifications import ( "context" "crypto/tls" + "errors" "fmt" "time" @@ -61,7 +62,7 @@ func smtpAuthTypeFromModeInternal(mode models.EmailAuthMode) mail.SMTPAuthType { func buildMailClientInternal(config models.EmailConfig, options smtpBuildOptions) (*mail.Client, error) { if config.SMTPHost == "" { - return nil, fmt.Errorf("SMTP host is empty") + return nil, errors.New("SMTP host is empty") } if config.SMTPPort < 1 || config.SMTPPort > 65535 { return nil, fmt.Errorf("invalid SMTP port: %d", config.SMTPPort) @@ -113,17 +114,17 @@ func SendEmail(ctx context.Context, config models.EmailConfig, subject, htmlBody func sendEmailInternal(ctx context.Context, config models.EmailConfig, subject, htmlBody string, options smtpBuildOptions) error { if ctx == nil { - return fmt.Errorf("email send context is required") + return errors.New("email send context is required") } if err := ctx.Err(); err != nil { return fmt.Errorf("email send canceled: %w", err) } if config.FromAddress == "" { - return fmt.Errorf("from address is required") + return errors.New("from address is required") } if len(config.ToAddresses) == 0 { - return fmt.Errorf("at least one recipient is required") + return errors.New("at least one recipient is required") } client, err := buildMailClientInternal(config, options) diff --git a/backend/pkg/utils/notifications/generic_sender.go b/backend/pkg/utils/notifications/generic_sender.go index 8f76dff999..71e026845e 100644 --- a/backend/pkg/utils/notifications/generic_sender.go +++ b/backend/pkg/utils/notifications/generic_sender.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -21,7 +22,7 @@ import ( // BuildGenericURL and sendGenericDirectInternal. func resolveWebhookURLInternal(config models.GenericConfig) (*url.URL, error) { if config.WebhookURL == "" { - return nil, fmt.Errorf("webhook URL is empty") + return nil, errors.New("webhook URL is empty") } parsed, err := url.Parse(config.WebhookURL) @@ -43,7 +44,7 @@ func resolveWebhookURLInternal(config models.GenericConfig) (*url.URL, error) { } if parsed.Host == "" { - return nil, fmt.Errorf("invalid webhook URL: missing host") + return nil, errors.New("invalid webhook URL: missing host") } switch strings.ToLower(parsed.Scheme) { @@ -128,7 +129,7 @@ func BuildGenericURL(config models.GenericConfig) (string, error) { // but embed a success/failure indicator inside the JSON body. func SendGenericWithTitle(ctx context.Context, config models.GenericConfig, title, message string) error { if config.WebhookURL == "" { - return fmt.Errorf("webhook URL is empty") + return errors.New("webhook URL is empty") } // When the caller needs response-body validation we make the HTTP request diff --git a/backend/pkg/utils/notifications/gotify_sender.go b/backend/pkg/utils/notifications/gotify_sender.go index 36cb47a3b4..9661f5bb49 100644 --- a/backend/pkg/utils/notifications/gotify_sender.go +++ b/backend/pkg/utils/notifications/gotify_sender.go @@ -2,8 +2,10 @@ package notifications import ( "context" + "errors" "fmt" "net/url" + "strconv" "strings" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -15,11 +17,11 @@ import ( // URL example: gotify://host[:port][/path]/token[?query] func BuildGotifyURL(config models.GotifyConfig) (string, error) { if config.Host == "" { - return "", fmt.Errorf("gotify host is required") + return "", errors.New("gotify host is required") } if config.Token == "" { - return "", fmt.Errorf("gotify token is required") + return "", errors.New("gotify token is required") } u := &url.URL{ @@ -42,7 +44,7 @@ func BuildGotifyURL(config models.GotifyConfig) (string, error) { q := u.Query() // Always set priority if it's within valid range, 0 is a valid priority - q.Set("priority", fmt.Sprintf("%d", config.Priority)) + q.Set("priority", strconv.Itoa(config.Priority)) if config.Title != "" { q.Set("title", config.Title) diff --git a/backend/pkg/utils/notifications/matrix_sender.go b/backend/pkg/utils/notifications/matrix_sender.go index 26610c68fb..020eefebc6 100644 --- a/backend/pkg/utils/notifications/matrix_sender.go +++ b/backend/pkg/utils/notifications/matrix_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "net/url" "strings" @@ -15,7 +16,7 @@ import ( // URL example: matrix://user:password@host[:port]/[?rooms=!roomID1[,roomAlias2]][&disableTLS=yes] func BuildMatrixURL(config models.MatrixConfig) (string, error) { if config.Host == "" { - return "", fmt.Errorf("matrix host is required") + return "", errors.New("matrix host is required") } // Build the base URL diff --git a/backend/pkg/utils/notifications/ntfy_sender.go b/backend/pkg/utils/notifications/ntfy_sender.go index fda1f926c4..662c82bd08 100644 --- a/backend/pkg/utils/notifications/ntfy_sender.go +++ b/backend/pkg/utils/notifications/ntfy_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "net/url" "strings" @@ -15,7 +16,7 @@ import ( // URL example: ntfy://[user:password@]host[:port]/topic[?query] func BuildNtfyURL(config models.NtfyConfig) (string, error) { if config.Topic == "" { - return "", fmt.Errorf("ntfy topic is required") + return "", errors.New("ntfy topic is required") } // Default host to ntfy.sh if not specified @@ -87,7 +88,7 @@ func BuildNtfyURL(config models.NtfyConfig) (string, error) { // SendNtfy sends a message via Shoutrrr Ntfy using proper service configuration func SendNtfy(ctx context.Context, config models.NtfyConfig, message string) error { if config.Topic == "" { - return fmt.Errorf("ntfy topic is required") + return errors.New("ntfy topic is required") } shoutrrrURL, err := BuildNtfyURL(config) diff --git a/backend/pkg/utils/notifications/pushover_sender.go b/backend/pkg/utils/notifications/pushover_sender.go index ef5d7ae042..72faaf6135 100644 --- a/backend/pkg/utils/notifications/pushover_sender.go +++ b/backend/pkg/utils/notifications/pushover_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "net/url" "strconv" @@ -18,13 +19,13 @@ func BuildPushoverURL(config models.PushoverConfig) (string, error) { token := strings.TrimSpace(config.Token) if user == "" { - return "", fmt.Errorf("pushover user key is required") + return "", errors.New("pushover user key is required") } if token == "" { - return "", fmt.Errorf("pushover token is required") + return "", errors.New("pushover token is required") } if config.Priority < -2 || config.Priority > 2 { - return "", fmt.Errorf("pushover priority must be between -2 and 2") + return "", errors.New("pushover priority must be between -2 and 2") } u := &url.URL{ @@ -58,13 +59,13 @@ func BuildPushoverURL(config models.PushoverConfig) (string, error) { // SendPushover sends a message via Shoutrrr Pushover using proper service configuration. func SendPushover(ctx context.Context, config models.PushoverConfig, message string) error { if strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("pushover token is empty") + return errors.New("pushover token is empty") } if strings.TrimSpace(config.User) == "" { - return fmt.Errorf("pushover user key is empty") + return errors.New("pushover user key is empty") } if config.Priority < -2 || config.Priority > 2 { - return fmt.Errorf("pushover priority must be between -2 and 2") + return errors.New("pushover priority must be between -2 and 2") } shoutrrrURL, err := BuildPushoverURL(config) diff --git a/backend/pkg/utils/notifications/signal_sender.go b/backend/pkg/utils/notifications/signal_sender.go index 63900f58d9..10d37dd80c 100644 --- a/backend/pkg/utils/notifications/signal_sender.go +++ b/backend/pkg/utils/notifications/signal_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -29,26 +30,26 @@ func BuildSignalURL(config models.SignalConfig) (string, error) { // SendSignal sends a message via Shoutrrr Signal using proper service configuration func SendSignal(ctx context.Context, config models.SignalConfig, message string) error { if config.Host == "" { - return fmt.Errorf("signal host is empty") + return errors.New("signal host is empty") } if config.Port == 0 { - return fmt.Errorf("signal port is not set") + return errors.New("signal port is not set") } if config.Source == "" { - return fmt.Errorf("signal source phone number is empty") + return errors.New("signal source phone number is empty") } if len(config.Recipients) == 0 { - return fmt.Errorf("no signal recipients configured") + return errors.New("no signal recipients configured") } // Validate authentication hasBasicAuth := config.User != "" && config.Password != "" hasTokenAuth := config.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } shoutrrrURL, err := BuildSignalURL(config) diff --git a/backend/pkg/utils/notifications/slack_sender.go b/backend/pkg/utils/notifications/slack_sender.go index 6798ad883a..6c844fcffd 100644 --- a/backend/pkg/utils/notifications/slack_sender.go +++ b/backend/pkg/utils/notifications/slack_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -12,7 +13,7 @@ import ( // BuildSlackURL converts SlackConfig to Shoutrrr URL format using shoutrrr's Config func BuildSlackURL(config models.SlackConfig) (string, error) { if config.Token == "" { - return "", fmt.Errorf("slack token is required") + return "", errors.New("slack token is required") } // Parse the token to get the token object @@ -38,7 +39,7 @@ func BuildSlackURL(config models.SlackConfig) (string, error) { // SendSlack sends a message via Shoutrrr Slack using proper service configuration func SendSlack(ctx context.Context, config models.SlackConfig, message string) error { if config.Token == "" { - return fmt.Errorf("slack token is empty") + return errors.New("slack token is empty") } shoutrrrURL, err := BuildSlackURL(config) diff --git a/backend/pkg/utils/notifications/telegram_sender.go b/backend/pkg/utils/notifications/telegram_sender.go index 05b5af6dc0..be58243a6d 100644 --- a/backend/pkg/utils/notifications/telegram_sender.go +++ b/backend/pkg/utils/notifications/telegram_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -40,10 +41,10 @@ func BuildTelegramURL(config models.TelegramConfig) (string, error) { // SendTelegram sends a message via Shoutrrr Telegram using proper service configuration func SendTelegram(ctx context.Context, config models.TelegramConfig, message string) error { if config.BotToken == "" { - return fmt.Errorf("telegram bot token is empty") + return errors.New("telegram bot token is empty") } if len(config.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } shoutrrrURL, err := BuildTelegramURL(config) diff --git a/backend/pkg/utils/signals/signals.go b/backend/pkg/utils/signals/signals.go index e59ca6d2da..a6aabd5282 100644 --- a/backend/pkg/utils/signals/signals.go +++ b/backend/pkg/utils/signals/signals.go @@ -24,7 +24,7 @@ var onlyOneSignalHandler = make(chan struct{}) func SignalContext(parentCtx context.Context) context.Context { close(onlyOneSignalHandler) // Panics when called twice - ctx, cancel := context.WithCancel(parentCtx) //nolint:gosec // cancel is intentionally triggered by the signal handler goroutine below. + ctx, cancel := context.WithCancel(parentCtx) sigCh := make(chan os.Signal, 2) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) diff --git a/cli/internal/ci/ci_detect.go b/cli/internal/ci/ci_detect.go index cf922b0277..2fd408addb 100644 --- a/cli/internal/ci/ci_detect.go +++ b/cli/internal/ci/ci_detect.go @@ -3,6 +3,7 @@ package ci import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -36,7 +37,7 @@ func DetectToken(ctx context.Context, provider string, audience string, getenv f if token := strings.TrimSpace(getenv("CI_JOB_JWT_V2")); token != "" { return token, ProviderGitLab, nil } - return "", "", fmt.Errorf("no supported CI OIDC token source detected") + return "", "", errors.New("no supported CI OIDC token source detected") case ProviderGitHub: token, err := mintGitHubActionsTokenInternal(ctx, audience, getenv, httpClient) if err != nil { @@ -46,11 +47,11 @@ func DetectToken(ctx context.Context, provider string, audience string, getenv f case ProviderGitLab: token := strings.TrimSpace(getenv("CI_JOB_JWT_V2")) if token == "" { - return "", "", fmt.Errorf("CI_JOB_JWT_V2 is not set; pass a GitLab id_tokens value with --token") + return "", "", errors.New("CI_JOB_JWT_V2 is not set; pass a GitLab id_tokens value with --token") } return token, ProviderGitLab, nil case ProviderGeneric: - return "", "", fmt.Errorf("generic provider requires --token, --token-file, or --token-stdin") + return "", "", errors.New("generic provider requires --token, --token-file, or --token-stdin") default: return "", "", fmt.Errorf("unsupported federated provider %q", provider) } @@ -60,7 +61,7 @@ func mintGitHubActionsTokenInternal(ctx context.Context, audience string, getenv requestURL := strings.TrimSpace(getenv("ACTIONS_ID_TOKEN_REQUEST_URL")) requestToken := strings.TrimSpace(getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")) if requestURL == "" || requestToken == "" { - return "", fmt.Errorf("GitHub Actions OIDC request environment is not set") + return "", errors.New("GitHub Actions OIDC request environment is not set") } parsedURL, err := url.Parse(requestURL) @@ -102,7 +103,7 @@ func mintGitHubActionsTokenInternal(ctx context.Context, audience string, getenv } token := strings.TrimSpace(payload.Value) if token == "" { - return "", fmt.Errorf("GitHub Actions OIDC response did not include a token") + return "", errors.New("GitHub Actions OIDC response did not include a token") } return token, nil } diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index c23935c6ce..d875577bfd 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -45,7 +45,7 @@ import ( ) const ( - headerAPIKey = "X-API-KEY" //nolint:gosec + headerAPIKey = "X-Api-Key" // #nosec G101 -- HTTP header name, not a credential defaultTimeout = 10 * time.Minute defaultEnvID = "0" maxErrorBody = 4096 @@ -280,7 +280,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body any) (*h req.Header.Set("Accept", "application/json") start := time.Now() logger.GetLogger().Debug("Sending request", "method", method, "url", fullURL, "env_id", c.envID, "streaming_body", true) - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Request failed", "method", method, "url", fullURL, "env_id", c.envID, "duration", time.Since(start).String(), "error", err) return nil, fmt.Errorf("request failed: %w", err) @@ -323,7 +323,7 @@ func (c *Client) RequestRaw(ctx context.Context, method, path string, body io.Re start := time.Now() logger.GetLogger().Debug("Sending raw request", "method", method, "url", fullURL, "env_id", c.envID) - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Raw request failed", "method", method, "url", fullURL, "env_id", c.envID, "duration", time.Since(start).String(), "error", err) return nil, fmt.Errorf("request failed: %w", err) @@ -335,7 +335,7 @@ func (c *Client) RequestRaw(ctx context.Context, method, path string, body io.Re func (c *Client) resolveURL(path string) (string, error) { if strings.TrimSpace(path) == "" { - return "", fmt.Errorf("invalid path: empty") + return "", errors.New("invalid path: empty") } if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { if _, err := url.Parse(path); err != nil { @@ -348,7 +348,7 @@ func (c *Client) resolveURL(path string) (string, error) { return "", fmt.Errorf("invalid path: %w", err) } if c.baseURLParsed == nil { - return "", fmt.Errorf("invalid base URL") + return "", errors.New("invalid base URL") } return c.baseURLParsed.ResolveReference(rel).String(), nil } @@ -373,7 +373,7 @@ func (c *Client) doRequest(ctx context.Context, method, fullURL string, bodyByte start := time.Now() logger.GetLogger().Debug("Sending request", "method", method, "url", fullURL, "env_id", c.envID, "attempt", attempt, "max_attempts", attempts, "body_bytes", len(bodyBytes)) - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Request failed", "method", method, "url", fullURL, "env_id", c.envID, "attempt", attempt, "duration", time.Since(start).String(), "error", err) if attempt < attempts && c.shouldRetry(method, 0, err) { @@ -478,7 +478,7 @@ func (c *Client) applyAuth(req *http.Request) { func (c *Client) refreshAccessToken(ctx context.Context) error { if c.refreshToken == "" { - return fmt.Errorf("refresh token not configured; run `arcane auth login`") + return errors.New("refresh token not configured; run `arcane auth login`") } bodyBytes, err := json.Marshal(map[string]string{"refreshToken": c.refreshToken}) @@ -487,7 +487,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { } if c.baseURLParsed == nil { - return fmt.Errorf("invalid base URL") + return errors.New("invalid base URL") } refreshURL := c.baseURLParsed.ResolveReference(&url.URL{Path: types.Endpoints.AuthRefresh()}).String() logger.GetLogger().Debug("Sending token refresh request", "url", refreshURL) @@ -500,7 +500,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { req.Header.Set("Accept", "application/json") start := time.Now() - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Token refresh request failed", "url", refreshURL, "duration", time.Since(start).String(), "error", err) return fmt.Errorf("token refresh failed: %w", err) @@ -522,7 +522,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { return fmt.Errorf("failed to parse refresh response: %w", err) } if !result.Success || result.Data.Token == "" { - return fmt.Errorf("token refresh failed: unexpected response from server") + return errors.New("token refresh failed: unexpected response from server") } newRefresh := result.Data.RefreshToken @@ -658,7 +658,7 @@ func DecodeResponseStrict[T any](resp *http.Response) (*APIResponse[T], error) { if strings.TrimSpace(result.Error) != "" { return &result, fmt.Errorf("API error: %s", result.Error) } - return &result, fmt.Errorf("API error: request was not successful") + return &result, errors.New("API error: request was not successful") } return &result, nil diff --git a/cli/internal/cmdutil/context.go b/cli/internal/cmdutil/context.go index 0cb8db657f..3e6e140e60 100644 --- a/cli/internal/cmdutil/context.go +++ b/cli/internal/cmdutil/context.go @@ -1,6 +1,7 @@ package cmdutil import ( + "errors" "fmt" "strings" @@ -12,7 +13,7 @@ import ( // ClientFromCommand returns a configured authenticated client for the command. func ClientFromCommand(cmd *cobra.Command) (*client.Client, error) { if cmd == nil { - return nil, fmt.Errorf("nil command") + return nil, errors.New("nil command") } if app, ok := runtimectx.From(cmd.Context()); ok { return app.Client() @@ -23,7 +24,7 @@ func ClientFromCommand(cmd *cobra.Command) (*client.Client, error) { // UnauthClientFromCommand returns a configured unauthenticated client for the command. func UnauthClientFromCommand(cmd *cobra.Command) (*client.Client, error) { if cmd == nil { - return nil, fmt.Errorf("nil command") + return nil, errors.New("nil command") } if app, ok := runtimectx.From(cmd.Context()); ok { return app.UnauthClient() diff --git a/cli/internal/cmdutil/response.go b/cli/internal/cmdutil/response.go index c2f854b4a6..8a2bfe7c58 100644 --- a/cli/internal/cmdutil/response.go +++ b/cli/internal/cmdutil/response.go @@ -2,6 +2,7 @@ package cmdutil import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -27,7 +28,7 @@ func (e *HTTPStatusError) Error() string { // EnsureSuccessStatus returns an error for non-2xx responses. func EnsureSuccessStatus(resp *http.Response) error { if resp == nil { - return fmt.Errorf("nil HTTP response") + return errors.New("nil HTTP response") } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil @@ -45,7 +46,7 @@ func DecodeJSON[T any](resp *http.Response, out *T) error { return err } if out == nil { - return fmt.Errorf("nil decode target") + return errors.New("nil decode target") } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("failed to parse response: %w", err) diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go index 4e54b679d3..6182042274 100644 --- a/cli/internal/output/output.go +++ b/cli/internal/output/output.go @@ -102,6 +102,8 @@ func SetColorEnabled(enabled bool) { // Success prints a success message in green. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Success(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(successStyle, msg)) @@ -110,6 +112,8 @@ func Success(format string, a ...any) { // Error prints an error message in red. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Error(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(errorStyle, msg)) @@ -118,6 +122,8 @@ func Error(format string, a ...any) { // Warning prints a warning message in yellow. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Warning(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(warnStyle, msg)) @@ -126,6 +132,8 @@ func Warning(format string, a ...any) { // Info prints an info message in cyan. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Info(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(infoStyle, msg)) @@ -134,6 +142,8 @@ func Info(format string, a ...any) { // Header prints a header message in bold white. // Use this to introduce sections of output. The message is prefixed // with a newline for visual separation. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Header(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(headerStyle, msg)) @@ -141,6 +151,8 @@ func Header(format string, a ...any) { // Print prints a standard message without color formatting. // Use this for regular output that doesn't need status indication. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Print(format string, a ...any) { fmt.Printf(format+"\n", a...) } diff --git a/cli/internal/prompt/prompt.go b/cli/internal/prompt/prompt.go index cd2e46b699..f07347b43b 100644 --- a/cli/internal/prompt/prompt.go +++ b/cli/internal/prompt/prompt.go @@ -1,6 +1,7 @@ package prompt import ( + "errors" "fmt" "os" @@ -20,7 +21,7 @@ func Select(label string, options []string) (int, error) { return -1, fmt.Errorf("no %s options available", label) } if !IsInteractive() { - return -1, fmt.Errorf("interactive terminal required") + return -1, errors.New("interactive terminal required") } items := make([]list.Item, len(options)) @@ -37,16 +38,16 @@ func Select(label string, options []string) (int, error) { result, ok := finalModel.(selectModel) if !ok { - return -1, fmt.Errorf("failed to read selection") + return -1, errors.New("failed to read selection") } if result.canceled { - return -1, fmt.Errorf("selection canceled") + return -1, errors.New("selection canceled") } if result.choice < 0 { - return -1, fmt.Errorf("selection required") + return -1, errors.New("selection required") } if result.choice >= len(options) { - return -1, fmt.Errorf("selection out of range") + return -1, errors.New("selection out of range") } clearScreenAndShowSelection(label, options[result.choice]) diff --git a/cli/internal/types/config.go b/cli/internal/types/config.go index 0737b761b9..e0186418a4 100644 --- a/cli/internal/types/config.go +++ b/cli/internal/types/config.go @@ -1,7 +1,7 @@ package types import ( - "fmt" + "errors" "maps" "strings" ) @@ -81,11 +81,11 @@ type Config struct { // ServerURL is the base URL of the Arcane server (e.g., http://localhost:3552) ServerURL string `yaml:"server_url" mapstructure:"server_url"` // APIKey is the API key for authentication (sent as X-API-KEY) - APIKey string `yaml:"api_key,omitempty" mapstructure:"api_key"` //nolint:gosec // persisted config schema requires this field name + APIKey string `yaml:"api_key,omitempty" mapstructure:"api_key"` // JWTToken is the JWT access token for authentication (sent as Authorization: Bearer) - JWTToken string `yaml:"jwt_token,omitempty" mapstructure:"jwt_token"` //nolint:gosec // persisted config schema requires this field name + JWTToken string `yaml:"jwt_token,omitempty" mapstructure:"jwt_token"` // RefreshToken is the refresh token for obtaining new access tokens - RefreshToken string `yaml:"refresh_token,omitempty" mapstructure:"refresh_token"` //nolint:gosec // persisted config schema requires this field name + RefreshToken string `yaml:"refresh_token,omitempty" mapstructure:"refresh_token"` // DefaultEnvironment is the default environment ID to use DefaultEnvironment string `yaml:"default_environment,omitempty" mapstructure:"default_environment"` // FederatedAudience is the default token audience for `arcane-cli auth federated` @@ -107,7 +107,7 @@ func (c *Config) HasAuth() bool { // This is useful for commands like `auth login` that do not require prior authentication. func (c *Config) ValidateServerURL() error { if c.ServerURL == "" { - return fmt.Errorf("server_url is not configured. Run: arcane config set --server-url ") + return errors.New("server_url is not configured. Run: arcane config set --server-url ") } return nil } @@ -120,7 +120,7 @@ func (c *Config) Validate() error { return err } if !c.HasAuth() { - return fmt.Errorf("authentication is not configured. Run: arcane config set --api-key OR arcane auth login") + return errors.New("authentication is not configured. Run: arcane config set --api-key OR arcane auth login") } return nil } diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index 7cc1233d83..350771a214 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -344,6 +344,7 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut } // Auth endpoints + func (e ArcaneApiEndpoints) AuthLogout() string { return e.AuthLogoutEndpoint } func (e ArcaneApiEndpoints) AuthMe() string { return e.AuthMeEndpoint } func (e ArcaneApiEndpoints) AuthPassword() string { return e.AuthPasswordEndpoint } @@ -351,15 +352,18 @@ func (e ArcaneApiEndpoints) AuthRefresh() string { return e.AuthRefreshEndpoin func (e ArcaneApiEndpoints) AuthFederated() string { return e.AuthFederatedEndpoint } // OIDC endpoints + func (e ArcaneApiEndpoints) OIDCDeviceCode() string { return e.OIDCDeviceCodeEndpoint } func (e ArcaneApiEndpoints) OIDCDeviceToken() string { return e.OIDCDeviceTokenEndpoint } func (e ArcaneApiEndpoints) OIDCStatus() string { return e.OIDCStatusEndpoint } // API Key endpoints + func (e ArcaneApiEndpoints) ApiKeys() string { return e.ApiKeysEndpoint } func (e ArcaneApiEndpoints) ApiKey(id string) string { return fmt.Sprintf(e.ApiKeyEndpoint, id) } // User endpoints + func (e ArcaneApiEndpoints) Users() string { return e.UsersEndpoint } func (e ArcaneApiEndpoints) User(id string) string { return fmt.Sprintf(e.UserEndpoint, id) } func (e ArcaneApiEndpoints) UserRoleAssignments(userID string) string { @@ -367,6 +371,7 @@ func (e ArcaneApiEndpoints) UserRoleAssignments(userID string) string { } // Role (RBAC) endpoints + func (e ArcaneApiEndpoints) Roles() string { return e.RolesEndpoint } func (e ArcaneApiEndpoints) Role(id string) string { return fmt.Sprintf(e.RoleEndpoint, id) } func (e ArcaneApiEndpoints) RolesAvailablePermissions() string { @@ -374,12 +379,14 @@ func (e ArcaneApiEndpoints) RolesAvailablePermissions() string { } // OIDC role mapping endpoints + func (e ArcaneApiEndpoints) OidcRoleMappings() string { return e.OidcRoleMappingsEndpoint } func (e ArcaneApiEndpoints) OidcRoleMapping(id string) string { return fmt.Sprintf(e.OidcRoleMappingEndpoint, id) } // Environment endpoints + func (e ArcaneApiEndpoints) Environments() string { return e.EnvironmentsEndpoint } func (e ArcaneApiEndpoints) Environment(id string) string { @@ -395,6 +402,7 @@ func (e ArcaneApiEndpoints) EnvironmentVersion(envID string) string { } // Container endpoints + func (e ArcaneApiEndpoints) Containers(envID string) string { return fmt.Sprintf(e.ContainersEndpoint, envID) } @@ -428,6 +436,7 @@ func (e ArcaneApiEndpoints) ContainersCounts(envID string) string { } // Image endpoints + func (e ArcaneApiEndpoints) Images(envID string) string { return fmt.Sprintf(e.ImagesEndpoint, envID) } func (e ArcaneApiEndpoints) Image(envID, imageID string) string { @@ -451,6 +460,7 @@ func (e ArcaneApiEndpoints) ImagesUpload(envID string) string { } // Image Update endpoints + func (e ArcaneApiEndpoints) ImageUpdatesCheck(envID string) string { return fmt.Sprintf(e.ImageUpdatesCheckEndpoint, envID) } @@ -468,6 +478,7 @@ func (e ArcaneApiEndpoints) ImageUpdatesSummary(envID string) string { } // Network endpoints + func (e ArcaneApiEndpoints) Networks(envID string) string { return fmt.Sprintf(e.NetworksEndpoint, envID) } @@ -485,6 +496,7 @@ func (e ArcaneApiEndpoints) NetworksPrune(envID string) string { } // Volume endpoints + func (e ArcaneApiEndpoints) Volumes(envID string) string { return fmt.Sprintf(e.VolumesEndpoint, envID) } @@ -510,6 +522,7 @@ func (e ArcaneApiEndpoints) VolumeUsage(envID, volumeName string) string { } // Project endpoints + func (e ArcaneApiEndpoints) Projects(envID string) string { return fmt.Sprintf(e.ProjectsEndpoint, envID) } @@ -551,6 +564,7 @@ func (e ArcaneApiEndpoints) ProjectIncludes(envID, projectID string) string { } // System endpoints + func (e ArcaneApiEndpoints) SystemPrune(envID string) string { return fmt.Sprintf(e.SystemPruneEndpoint, envID) } @@ -584,6 +598,7 @@ func (e ArcaneApiEndpoints) SystemUpgradeCheck(envID string) string { } // Updater endpoints + func (e ArcaneApiEndpoints) UpdaterStatus(envID string) string { return fmt.Sprintf(e.UpdaterStatusEndpoint, envID) } @@ -597,11 +612,13 @@ func (e ArcaneApiEndpoints) UpdaterHistory(envID string) string { } // Job schedule endpoints + func (e ArcaneApiEndpoints) JobSchedules(envID string) string { return fmt.Sprintf(e.JobSchedulesEndpoint, envID) } // Settings endpoints + func (e ArcaneApiEndpoints) Settings(envID string) string { return fmt.Sprintf(e.SettingsEndpoint, envID) } @@ -611,6 +628,7 @@ func (e ArcaneApiEndpoints) SettingsPublic(envID string) string { } // Notification endpoints + func (e ArcaneApiEndpoints) NotificationsSettings(envID string) string { return fmt.Sprintf(e.NotificationsSettingsEndpoint, envID) } @@ -624,6 +642,7 @@ func (e ArcaneApiEndpoints) NotificationsTestProvider(envID, provider string) st } // Container Registry endpoints + func (e ArcaneApiEndpoints) ContainerRegistries() string { return e.ContainerRegistriesEndpoint } func (e ArcaneApiEndpoints) ContainerRegistry(id string) string { @@ -635,6 +654,7 @@ func (e ArcaneApiEndpoints) ContainerRegistryTest(id string) string { } // Event endpoints + func (e ArcaneApiEndpoints) Events() string { return e.EventsEndpoint } func (e ArcaneApiEndpoints) Event(id string) string { return fmt.Sprintf(e.EventEndpoint, id) } func (e ArcaneApiEndpoints) EventsEnvironment(envID string) string { @@ -642,6 +662,7 @@ func (e ArcaneApiEndpoints) EventsEnvironment(envID string) string { } // Template endpoints + func (e ArcaneApiEndpoints) Templates() string { return e.TemplatesEndpoint } func (e ArcaneApiEndpoints) Template(id string) string { return fmt.Sprintf(e.TemplateEndpoint, id) } func (e ArcaneApiEndpoints) TemplatesAll() string { return e.TemplatesAllEndpoint } @@ -660,11 +681,13 @@ func (e ArcaneApiEndpoints) TemplateDownload(id string) string { func (e ArcaneApiEndpoints) TemplateFetch() string { return e.TemplateFetchEndpoint } // Dashboard endpoints + func (e ArcaneApiEndpoints) Dashboard(envID string) string { return fmt.Sprintf(e.DashboardEndpoint, envID) } // GitOps Sync endpoints + func (e ArcaneApiEndpoints) GitOpsSyncs(envID string) string { return fmt.Sprintf(e.GitOpsSyncsEndpoint, envID) } @@ -685,6 +708,7 @@ func (e ArcaneApiEndpoints) GitOpsSyncsImport(envID string) string { } // Git Repository endpoints + func (e ArcaneApiEndpoints) GitRepositories() string { return e.GitRepositoriesEndpoint } func (e ArcaneApiEndpoints) GitRepository(id string) string { return fmt.Sprintf(e.GitRepositoryEndpoint, id) diff --git a/cli/pkg/admin/apikeys/cmd.go b/cli/pkg/admin/apikeys/cmd.go index 534041727f..3d44fc1519 100644 --- a/cli/pkg/admin/apikeys/cmd.go +++ b/cli/pkg/admin/apikeys/cmd.go @@ -1,7 +1,9 @@ package apikeys import ( + "errors" "fmt" + "strconv" "strings" "time" @@ -116,7 +118,7 @@ var listCmd = &cobra.Command{ key.ID, key.Name, description, - fmt.Sprintf("%d", len(key.Permissions)), + strconv.Itoa(len(key.Permissions)), key.CreatedAt.Format("2006-01-02 15:04"), lastUsed, } @@ -146,7 +148,7 @@ var createCmd = &cobra.Command{ return err } if len(grants) == 0 { - return fmt.Errorf("at least one --permission is required (e.g. --permission containers:list)") + return errors.New("at least one --permission is required (e.g. --permission containers:list)") } createReq := apikey.CreateApiKey{ Name: args[0], @@ -274,7 +276,7 @@ var getCmd = &cobra.Command{ if result.Data.ExpiresAt != nil { output.KeyValue("Expires", result.Data.ExpiresAt.Format("2006-01-02 15:04")) } - output.KeyValue("Permissions", fmt.Sprintf("%d", len(result.Data.Permissions))) + output.KeyValue("Permissions", strconv.Itoa(len(result.Data.Permissions))) if len(result.Data.Permissions) > 0 { rows := make([][]string, len(result.Data.Permissions)) for i, g := range result.Data.Permissions { diff --git a/cli/pkg/admin/notifications/cmd.go b/cli/pkg/admin/notifications/cmd.go index 16548140f6..5d12909493 100644 --- a/cli/pkg/admin/notifications/cmd.go +++ b/cli/pkg/admin/notifications/cmd.go @@ -3,6 +3,7 @@ package notifications import ( "encoding/json" "fmt" + "strconv" "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" @@ -64,9 +65,9 @@ var settingsGetCmd = &cobra.Command{ rows := make([][]string, len(result)) for i, setting := range result { rows[i] = []string{ - fmt.Sprintf("%d", setting.ID), + strconv.FormatUint(uint64(setting.ID), 10), string(setting.Provider), - fmt.Sprintf("%t", setting.Enabled), + strconv.FormatBool(setting.Enabled), } } diff --git a/cli/pkg/admin/roles/cmd.go b/cli/pkg/admin/roles/cmd.go index ed89f4e920..0336da3507 100644 --- a/cli/pkg/admin/roles/cmd.go +++ b/cli/pkg/admin/roles/cmd.go @@ -6,7 +6,9 @@ package roles import ( + "errors" "fmt" + "strconv" "strings" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" @@ -94,8 +96,8 @@ var listCmd = &cobra.Command{ r.ID, r.Name, roleType, - fmt.Sprintf("%d", r.AssignedUserCount), - fmt.Sprintf("%d", len(r.Permissions)), + strconv.Itoa(r.AssignedUserCount), + strconv.Itoa(len(r.Permissions)), } } output.Table(headers, rows) @@ -135,9 +137,9 @@ var getCmd = &cobra.Command{ if result.Data.Description != nil { output.KeyValue("Description", *result.Data.Description) } - output.KeyValue("Built-in", fmt.Sprintf("%t", result.Data.BuiltIn)) - output.KeyValue("Assigned users", fmt.Sprintf("%d", result.Data.AssignedUserCount)) - output.KeyValue("Permissions", fmt.Sprintf("%d", len(result.Data.Permissions))) + output.KeyValue("Built-in", strconv.FormatBool(result.Data.BuiltIn)) + output.KeyValue("Assigned users", strconv.Itoa(result.Data.AssignedUserCount)) + output.KeyValue("Permissions", strconv.Itoa(len(result.Data.Permissions))) if len(result.Data.Permissions) > 0 { rows := make([][]string, len(result.Data.Permissions)) for i, p := range result.Data.Permissions { @@ -155,10 +157,10 @@ var createCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if roleCreateName == "" { - return fmt.Errorf("--name is required") + return errors.New("--name is required") } if len(roleCreatePermissions) == 0 { - return fmt.Errorf("at least one --permission is required") + return errors.New("at least one --permission is required") } c, err := cmdutil.ClientFromCommand(cmd) if err != nil { @@ -188,7 +190,7 @@ var createCmd = &cobra.Command{ } output.Success("Role %s created", result.Data.Name) output.KeyValue("ID", result.Data.ID) - output.KeyValue("Permissions", fmt.Sprintf("%d", len(result.Data.Permissions))) + output.KeyValue("Permissions", strconv.Itoa(len(result.Data.Permissions))) return nil }, } diff --git a/cli/pkg/admin/users/cmd.go b/cli/pkg/admin/users/cmd.go index 5c844de604..7683c7b8a8 100644 --- a/cli/pkg/admin/users/cmd.go +++ b/cli/pkg/admin/users/cmd.go @@ -77,11 +77,11 @@ func summarizeRoleAssignments(assignments []user.RoleAssignmentSummary) string { b := buckets[roleID] switch { case b.hasGlobal && b.envScoped == 0: - parts = append(parts, fmt.Sprintf("%s (global)", roleID)) + parts = append(parts, roleID+" (global)") case b.hasGlobal && b.envScoped > 0: parts = append(parts, fmt.Sprintf("%s (global +%d envs)", roleID, b.envScoped)) case b.envScoped == 1: - parts = append(parts, fmt.Sprintf("%s on 1 env", roleID)) + parts = append(parts, roleID+" on 1 env") default: parts = append(parts, fmt.Sprintf("%s on %d envs", roleID, b.envScoped)) } diff --git a/cli/pkg/auth/cmd.go b/cli/pkg/auth/cmd.go index c9ffda6a61..83c62468e3 100644 --- a/cli/pkg/auth/cmd.go +++ b/cli/pkg/auth/cmd.go @@ -2,9 +2,11 @@ package auth import ( "encoding/json" + "errors" "fmt" "io" "os" + "strconv" "strings" "time" @@ -86,7 +88,7 @@ var loginCmd = &cobra.Command{ for { if time.Now().After(expiresAt) { - return fmt.Errorf("device authorization expired; run login again") + return errors.New("device authorization expired; run login again") } select { @@ -116,9 +118,9 @@ var loginCmd = &cobra.Command{ ticker.Reset(pollInterval) continue case "expired_token": - return fmt.Errorf("device authorization expired; run login again") + return errors.New("device authorization expired; run login again") case "access_denied": - return fmt.Errorf("device authorization denied") + return errors.New("device authorization denied") default: return fmt.Errorf("device token exchange failed (status %d): %s", tokenResp.StatusCode, strings.TrimSpace(string(tokenBody))) } @@ -129,7 +131,7 @@ var loginCmd = &cobra.Command{ return fmt.Errorf("failed to parse token response: %w", err) } if !tokenResult.Success || tokenResult.Token == "" { - return fmt.Errorf("device token exchange failed: unexpected response from server") + return errors.New("device token exchange failed: unexpected response from server") } if cmdutil.JSONOutputEnabled(cmd) || jsonOutput { @@ -437,9 +439,9 @@ var oidcStatusCmd = &cobra.Command{ } output.Header("OIDC Status") - output.KeyValue("Environment Forced", fmt.Sprintf("%t", result.EnvForced)) - output.KeyValue("Environment Configured", fmt.Sprintf("%t", result.EnvConfigured)) - output.KeyValue("Merge Accounts", fmt.Sprintf("%t", result.MergeAccounts)) + output.KeyValue("Environment Forced", strconv.FormatBool(result.EnvForced)) + output.KeyValue("Environment Configured", strconv.FormatBool(result.EnvConfigured)) + output.KeyValue("Merge Accounts", strconv.FormatBool(result.MergeAccounts)) return nil }, } diff --git a/cli/pkg/auth/federated.go b/cli/pkg/auth/federated.go index 9e6592b741..4f4f20b23a 100644 --- a/cli/pkg/auth/federated.go +++ b/cli/pkg/auth/federated.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -44,7 +45,7 @@ GitHub Actions example: exportOutput, _ := cmd.Flags().GetBool("export") if cmdutil.JSONOutputEnabled(cmd) && exportOutput { - return fmt.Errorf("--json and --export cannot be used together") + return errors.New("--json and --export cannot be used together") } cfg, err := config.Load() @@ -73,7 +74,7 @@ GitHub Actions example: return err } if tokenResp.AccessToken == "" { - return fmt.Errorf("federated token exchange failed: empty access token") + return errors.New("federated token exchange failed: empty access token") } expiresAt := time.Now().UTC().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) @@ -149,7 +150,7 @@ func resolveFederatedSubjectTokenInternal(cmd *cobra.Command, provider string, a } token = strings.TrimSpace(string(data)) if token == "" { - return "", "", fmt.Errorf("token file is empty") + return "", "", errors.New("token file is empty") } return token, "file", nil } @@ -162,7 +163,7 @@ func resolveFederatedSubjectTokenInternal(cmd *cobra.Command, provider string, a } token = strings.TrimSpace(string(data)) if token == "" { - return "", "", fmt.Errorf("stdin token is empty") + return "", "", errors.New("stdin token is empty") } return token, "stdin", nil } diff --git a/cli/pkg/config/cmd.go b/cli/pkg/config/cmd.go index 367c0d6db4..6f541bc2a3 100644 --- a/cli/pkg/config/cmd.go +++ b/cli/pkg/config/cmd.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "io" "net/http" @@ -134,7 +135,7 @@ Legacy flag syntax (flags shown below) is still supported: updated = updated || updatedByFlags if !updated { - return fmt.Errorf("no configuration values provided. Use `arcane config set ` (e.g. `arcane config set server-url http://localhost:3552`) or legacy flags (--server-url, --api-key, --jwt-token, --environment, --log-level, --default-limit, --resource-limit)") + return errors.New("no configuration values provided. Use `arcane config set ` (e.g. `arcane config set server-url http://localhost:3552`) or legacy flags (--server-url, --api-key, --jwt-token, --environment, --log-level, --default-limit, --resource-limit)") } if err := config.Save(cfg); err != nil { @@ -186,7 +187,7 @@ var configTestCmd = &cobra.Command{ if cfg.JWTToken != "" { req.Header.Set("Authorization", "Bearer "+cfg.JWTToken) } else { - req.Header.Set("X-API-KEY", cfg.APIKey) + req.Header.Set("X-Api-Key", cfg.APIKey) } resp, err := httpClient.Do(req) @@ -365,7 +366,7 @@ func applyConfigSetFlags(cmd *cobra.Command, cfg *clitypes.Config) (bool, error) if cmd.Flags().Changed("default-limit") { if setDefaultLimit < 0 { - return false, fmt.Errorf("--default-limit must be >= 0") + return false, errors.New("--default-limit must be >= 0") } cfg.SetDefaultLimit(setDefaultLimit) if setDefaultLimit == 0 { diff --git a/cli/pkg/containers/cmd.go b/cli/pkg/containers/cmd.go index 3681ca22ad..642b96cc21 100644 --- a/cli/pkg/containers/cmd.go +++ b/cli/pkg/containers/cmd.go @@ -3,6 +3,7 @@ package containers import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -150,7 +151,7 @@ func buildContainersListPath(cmd *cobra.Command, c *client.Client, forceHasUpdat path := types.Endpoints.Containers(c.EnvID()) if containersAll { if cmd != nil && (cmd.Flags().Changed("limit") || cmd.Flags().Changed("start")) { - return "", fmt.Errorf("--all cannot be combined with explicit pagination flags") + return "", errors.New("--all cannot be combined with explicit pagination flags") } } else { var err error @@ -570,10 +571,10 @@ var containersCreateCmd = &cobra.Command{ // Validate required fields if req.Name == "" { - return fmt.Errorf("--name is required") + return errors.New("--name is required") } if req.Image == "" { - return fmt.Errorf("--image is required") + return errors.New("--image is required") } path := types.Endpoints.Containers(c.EnvID()) @@ -689,7 +690,7 @@ func containerDisplayName(details *container.Details) string { func resolveContainer(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*container.Details, bool, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, false, fmt.Errorf("container identifier is required") + return nil, false, errors.New("container identifier is required") } details, complete, found, err := fetchContainerByIdentifier(ctx, c, trimmed) diff --git a/cli/pkg/doctor/cmd.go b/cli/pkg/doctor/cmd.go index 529ba78fdb..e66824a6bc 100644 --- a/cli/pkg/doctor/cmd.go +++ b/cli/pkg/doctor/cmd.go @@ -2,6 +2,7 @@ package doctor import ( "encoding/json" + "errors" "fmt" "strings" @@ -41,7 +42,7 @@ var DoctorCmd = &cobra.Command{ cfg := app.Config() if cfg == nil { - return fmt.Errorf("configuration unavailable") + return errors.New("configuration unavailable") } rep := report{Healthy: true} @@ -123,7 +124,7 @@ var DoctorCmd = &cobra.Command{ } if !rep.Healthy { - return fmt.Errorf("diagnostics failed") + return errors.New("diagnostics failed") } return nil }, diff --git a/cli/pkg/environments/cmd.go b/cli/pkg/environments/cmd.go index 97da6a108f..6ff715618c 100644 --- a/cli/pkg/environments/cmd.go +++ b/cli/pkg/environments/cmd.go @@ -2,8 +2,10 @@ package environments import ( "encoding/json" + "errors" "fmt" "sort" + "strconv" "strings" "charm.land/lipgloss/v2" @@ -225,7 +227,7 @@ var switchCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if !prompt.IsInteractive() { - return fmt.Errorf("interactive terminal required; run `arcane config set environment ` instead") + return errors.New("interactive terminal required; run `arcane config set environment ` instead") } cfg, err := config.Load() @@ -251,7 +253,7 @@ var switchCmd = &cobra.Command{ } if len(result.Data) == 0 { - return fmt.Errorf("no environments available") + return errors.New("no environments available") } currentEnv := cfg.DefaultEnvironment @@ -344,7 +346,7 @@ var updateCmd = &cobra.Command{ var req environment.Update if cmd.Flags().Changed("enabled") && cmd.Flags().Changed("disabled") { - return fmt.Errorf("--enabled and --disabled are mutually exclusive") + return errors.New("--enabled and --disabled are mutually exclusive") } if cmd.Flags().Changed("name") { req.Name = &envUpdateName @@ -441,7 +443,7 @@ var versionCmd = &cobra.Command{ output.KeyValue("Revision", result.ShortRevision) output.KeyValue("Go Version", result.GoVersion) output.KeyValue("Build Time", result.BuildTime) - output.KeyValue("Update Available", fmt.Sprintf("%t", result.UpdateAvailable)) + output.KeyValue("Update Available", strconv.FormatBool(result.UpdateAvailable)) return nil }, } diff --git a/cli/pkg/generate/certs.go b/cli/pkg/generate/certs.go index 4c2d5b3a38..79afd820a5 100644 --- a/cli/pkg/generate/certs.go +++ b/cli/pkg/generate/certs.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" "fmt" "math/big" "net" @@ -126,7 +127,7 @@ type serverTLSPaths struct { func generateEdgeMTLSBundleInternal(outDir, envID string, appURL string) (*edgeMTLSPaths, error) { if envID == "" { - return nil, fmt.Errorf("env ID is required") + return nil, errors.New("env ID is required") } if err := os.MkdirAll(outDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create output directory: %w", err) @@ -155,7 +156,7 @@ func generateEdgeMTLSBundleInternal(outDir, envID string, appURL string) (*edgeM if trustDomain != "" { dnsSANs = append(dnsSANs, "arcane-agent."+trustDomain) } - clientTemplate, err := NewEdgeMTLSClientTemplate(fmt.Sprintf("arcane-edge-%s", envID), BuildEdgeMTLSURISAN(appURL, envID), dnsSANs) + clientTemplate, err := NewEdgeMTLSClientTemplate("arcane-edge-"+envID, BuildEdgeMTLSURISAN(appURL, envID), dnsSANs) if err != nil { return nil, err } @@ -187,15 +188,15 @@ func generateEdgeMTLSBundleInternal(outDir, envID string, appURL string) (*edgeM func generateServerTLSBundleInternal(outDir, commonName string, hosts []string, certName string, keyName string) (*serverTLSPaths, error) { if commonName == "" { - return nil, fmt.Errorf("common name is required") + return nil, errors.New("common name is required") } certName = filepath.Base(strings.TrimSpace(certName)) if certName == "." || certName == "" { - return nil, fmt.Errorf("certificate file name is required") + return nil, errors.New("certificate file name is required") } keyName = filepath.Base(strings.TrimSpace(keyName)) if keyName == "." || keyName == "" { - return nil, fmt.Errorf("key file name is required") + return nil, errors.New("key file name is required") } if err := os.MkdirAll(outDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create output directory: %w", err) @@ -249,7 +250,7 @@ func NewEdgeMTLSCATemplate() (*x509.Certificate, error) { func NewEdgeMTLSClientTemplate(commonName string, uriSAN *url.URL, dnsSANs []string) (*x509.Certificate, error) { commonName = strings.TrimSpace(commonName) if commonName == "" { - return nil, fmt.Errorf("common name is required") + return nil, errors.New("common name is required") } template, err := newCertificateTemplateInternal(commonName) if err != nil { @@ -278,7 +279,7 @@ func NewEdgeMTLSClientTemplate(commonName string, uriSAN *url.URL, dnsSANs []str func NewServerTLSTemplate(commonName string, hosts []string) (*x509.Certificate, error) { commonName = strings.TrimSpace(commonName) if commonName == "" { - return nil, fmt.Errorf("common name is required") + return nil, errors.New("common name is required") } template, err := newCertificateTemplateInternal(commonName) if err != nil { diff --git a/cli/pkg/gitops/cmd.go b/cli/pkg/gitops/cmd.go index 798fd2a264..59d7068d6a 100644 --- a/cli/pkg/gitops/cmd.go +++ b/cli/pkg/gitops/cmd.go @@ -3,6 +3,7 @@ package gitops import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -619,7 +620,7 @@ func init() { func resolveGitOpsSync(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*gitops.GitOpsSync, bool, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, false, fmt.Errorf("gitops sync identifier is required") + return nil, false, errors.New("gitops sync identifier is required") } resp, err := c.Get(ctx, types.Endpoints.GitOpsSync(c.EnvID(), trimmed)) diff --git a/cli/pkg/images/cmd.go b/cli/pkg/images/cmd.go index 7da6ef08a0..7ede2f47b7 100644 --- a/cli/pkg/images/cmd.go +++ b/cli/pkg/images/cmd.go @@ -33,6 +33,7 @@ package images import ( "context" "encoding/json" + "errors" "fmt" "io" "mime/multipart" @@ -88,7 +89,7 @@ var imagesListCmd = &cobra.Command{ return err } if imagesInUseOnly && imagesUnusedOnly { - return fmt.Errorf("--inuse and --unused cannot be used together") + return errors.New("--inuse and --unused cannot be used together") } path := types.Endpoints.Images(c.EnvID()) @@ -431,7 +432,7 @@ var imagesPullCmd = &cobra.Command{ } if currentID != event.ID { currentID = event.ID - progressUI.SetLabel(fmt.Sprintf("Downloading %s", event.ID)) + progressUI.SetLabel("Downloading " + event.ID) progressUI.SetTotal(event.ProgressDetail.Total) } progressUI.SetCurrent(event.ProgressDetail.Current) @@ -732,7 +733,7 @@ func init() { func resolveImageID(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (string, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return "", fmt.Errorf("image identifier is required") + return "", errors.New("image identifier is required") } resolvedID, found, err := resolveImageByID(ctx, c, trimmed) diff --git a/cli/pkg/images/updates/cmd.go b/cli/pkg/images/updates/cmd.go index 249b551c57..65544d61da 100644 --- a/cli/pkg/images/updates/cmd.go +++ b/cli/pkg/images/updates/cmd.go @@ -3,6 +3,7 @@ package updates import ( "encoding/json" "fmt" + "strconv" "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/output" @@ -140,7 +141,7 @@ var checkImageCmd = &cobra.Command{ } output.Header("Image Update Status") - output.KeyValue("Has Update", fmt.Sprintf("%t", result.Data.HasUpdate)) + output.KeyValue("Has Update", strconv.FormatBool(result.Data.HasUpdate)) if result.Data.HasUpdate { output.KeyValue("Update Type", result.Data.UpdateType) output.KeyValue("Current Version", result.Data.CurrentVersion) @@ -183,10 +184,10 @@ var summaryCmd = &cobra.Command{ } output.Header("Image Updates Summary") - output.KeyValue("Total Images", fmt.Sprintf("%d", result.Data.TotalImages)) - output.KeyValue("Images with Updates", fmt.Sprintf("%d", result.Data.ImagesWithUpdates)) - output.KeyValue("Digest Updates", fmt.Sprintf("%d", result.Data.DigestUpdates)) - output.KeyValue("Errors", fmt.Sprintf("%d", result.Data.ErrorsCount)) + output.KeyValue("Total Images", strconv.Itoa(result.Data.TotalImages)) + output.KeyValue("Images with Updates", strconv.Itoa(result.Data.ImagesWithUpdates)) + output.KeyValue("Digest Updates", strconv.Itoa(result.Data.DigestUpdates)) + output.KeyValue("Errors", strconv.Itoa(result.Data.ErrorsCount)) return nil }, } diff --git a/cli/pkg/jobs/cmd.go b/cli/pkg/jobs/cmd.go index 358ab493a0..d3b9f37378 100644 --- a/cli/pkg/jobs/cmd.go +++ b/cli/pkg/jobs/cmd.go @@ -2,6 +2,7 @@ package jobs import ( "encoding/json" + "errors" "fmt" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -91,7 +92,7 @@ var updateCmd = &cobra.Command{ } if req.EnvironmentHealthInterval == nil && req.EventCleanupInterval == nil && req.ExpiredSessionsCleanupInterval == nil { - return fmt.Errorf("no updates provided (set at least one interval flag)") + return errors.New("no updates provided (set at least one interval flag)") } resp, err := c.Put(cmd.Context(), types.Endpoints.JobSchedules(c.EnvID()), req) diff --git a/cli/pkg/networks/cmd.go b/cli/pkg/networks/cmd.go index 4780169a50..e92459e0f8 100644 --- a/cli/pkg/networks/cmd.go +++ b/cli/pkg/networks/cmd.go @@ -3,6 +3,7 @@ package networks import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -44,7 +45,7 @@ var listCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if inUseOnlyFlag && unusedOnlyFlag { - return fmt.Errorf("--inuse and --unused cannot be used together") + return errors.New("--inuse and --unused cannot be used together") } c, err := client.NewFromConfig() if err != nil { @@ -345,7 +346,7 @@ func shortID(id string) string { func resolveNetworkID(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (string, string, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return "", "", fmt.Errorf("network identifier is required") + return "", "", errors.New("network identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Network(c.EnvID(), trimmed)) diff --git a/cli/pkg/projects/cmd.go b/cli/pkg/projects/cmd.go index 51fcd94c01..9c1eccbe6e 100644 --- a/cli/pkg/projects/cmd.go +++ b/cli/pkg/projects/cmd.go @@ -3,12 +3,14 @@ package projects import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "time" @@ -116,8 +118,8 @@ func runProjectsList(cmd *cobra.Command, forceHasUpdateFilter bool) error { proj.Name, proj.Status, projectUpdateStatus(proj), - fmt.Sprintf("%d", imageCount), - fmt.Sprintf("%d", updatedCount), + strconv.Itoa(imageCount), + strconv.Itoa(updatedCount), } } output.Table(headers, rows) @@ -132,8 +134,8 @@ func runProjectsList(cmd *cobra.Command, forceHasUpdateFilter bool) error { proj.ID, proj.Name, proj.Status, - fmt.Sprintf("%d", proj.ServiceCount), - fmt.Sprintf("%d", proj.RunningCount), + strconv.Itoa(proj.ServiceCount), + strconv.Itoa(proj.RunningCount), proj.CreatedAt, } } @@ -656,7 +658,7 @@ func init() { func resolveProject(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*project.Details, bool, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, false, fmt.Errorf("project identifier is required") + return nil, false, errors.New("project identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Project(c.EnvID(), trimmed)) diff --git a/cli/pkg/registries/cmd.go b/cli/pkg/registries/cmd.go index 80110ae714..464bfbf1f7 100644 --- a/cli/pkg/registries/cmd.go +++ b/cli/pkg/registries/cmd.go @@ -2,6 +2,7 @@ package registries import ( "encoding/json" + "errors" "fmt" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -220,7 +221,7 @@ var updateCmd = &cobra.Command{ req := make(map[string]any) if cmd.Flags().Changed("enabled") && cmd.Flags().Changed("disabled") { - return fmt.Errorf("--enabled and --disabled are mutually exclusive") + return errors.New("--enabled and --disabled are mutually exclusive") } if cmd.Flags().Changed("url") { req["url"] = registryUpdateURL @@ -239,7 +240,7 @@ var updateCmd = &cobra.Command{ } if len(req) == 0 { - return fmt.Errorf("no updates provided; use --url, --username, --password, --enabled, or --disabled") + return errors.New("no updates provided; use --url, --username, --password, --enabled, or --disabled") } resp, err := c.Put(cmd.Context(), types.Endpoints.ContainerRegistry(args[0]), req) diff --git a/cli/pkg/repos/cmd.go b/cli/pkg/repos/cmd.go index a21b0bd3dc..058874a513 100644 --- a/cli/pkg/repos/cmd.go +++ b/cli/pkg/repos/cmd.go @@ -3,11 +3,13 @@ package repos import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "os" + "strconv" "strings" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -524,7 +526,7 @@ var filesCmd = &cobra.Command{ for i, node := range files { size := "" if node.Type == gitops.FileTreeNodeTypeFile { - size = fmt.Sprintf("%d", node.Size) + size = strconv.FormatInt(node.Size, 10) } rows[i] = []string{ node.Name, @@ -580,7 +582,7 @@ var syncCmd = &cobra.Command{ func resolveGitRepository(ctx context.Context, c *client.Client, identifier string) (*gitops.GitRepository, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, fmt.Errorf("repository identifier is required") + return nil, errors.New("repository identifier is required") } // Try direct GET by ID. @@ -598,7 +600,7 @@ func resolveGitRepository(ctx context.Context, c *client.Client, identifier stri } // Fallback: search via list endpoint. - listPath := fmt.Sprintf("%s?limit=200", types.Endpoints.GitRepositories()) + listPath := types.Endpoints.GitRepositories() + "?limit=200" listResp, err := c.Get(ctx, listPath) if err != nil { return nil, fmt.Errorf("failed to search repositories: %w", err) diff --git a/cli/pkg/selfupdate/cmd.go b/cli/pkg/selfupdate/cmd.go index 57c7d6ceb5..50703f131d 100644 --- a/cli/pkg/selfupdate/cmd.go +++ b/cli/pkg/selfupdate/cmd.go @@ -636,6 +636,7 @@ func downloadFileInternal(ctx context.Context, url, outputPath string) error { return nil } +//nolint:goprintffuncname // internal verbose-logging helper; printf-style by design func verboseCLIUpdateInternal(format string, args ...any) { if !cliUpdateVerbose { return @@ -666,7 +667,7 @@ func findChecksumInternal(checksums string, artifactNames ...string) (string, er } } - for _, line := range strings.Split(checksums, "\n") { + for line := range strings.SplitSeq(checksums, "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { continue @@ -681,7 +682,7 @@ func findChecksumInternal(checksums string, artifactNames ...string) (string, er func checksumEntryNamesInternal(checksums string) []string { names := make([]string, 0) - for _, line := range strings.Split(checksums, "\n") { + for line := range strings.SplitSeq(checksums, "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { continue diff --git a/cli/pkg/system/cmd.go b/cli/pkg/system/cmd.go index fed117495a..23700fbf90 100644 --- a/cli/pkg/system/cmd.go +++ b/cli/pkg/system/cmd.go @@ -3,6 +3,7 @@ package system import ( "encoding/json" "fmt" + "strconv" "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" @@ -327,7 +328,7 @@ var upgradeCheckCmd = &cobra.Command{ } output.Header("Upgrade Check") - output.KeyValue("Can Upgrade", fmt.Sprintf("%t", result.CanUpgrade)) + output.KeyValue("Can Upgrade", strconv.FormatBool(result.CanUpgrade)) output.KeyValue("Message", result.Message) if result.Error { output.KeyValue("Error", "true") diff --git a/cli/pkg/templates/cmd.go b/cli/pkg/templates/cmd.go index 96042c84ad..6c51cd3909 100644 --- a/cli/pkg/templates/cmd.go +++ b/cli/pkg/templates/cmd.go @@ -3,12 +3,14 @@ package templates import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" "path/filepath" "sort" + "strconv" "strings" "unicode" @@ -78,7 +80,7 @@ var listCmd = &cobra.Command{ if templateListAll { if cmd.Flags().Changed("limit") || cmd.Flags().Changed("start") { - return fmt.Errorf("--all cannot be combined with explicit pagination flags") + return errors.New("--all cannot be combined with explicit pagination flags") } path = types.Endpoints.TemplatesAll() resp, err := c.Get(cmd.Context(), path) @@ -223,8 +225,8 @@ var contentCmd = &cobra.Command{ output.Header("Template Content") output.KeyValue("Name", result.Data.Template.Name) output.KeyValue("Description", result.Data.Template.Description) - output.KeyValue("Services", fmt.Sprintf("%d", len(result.Data.Services))) - output.KeyValue("Env Variables", fmt.Sprintf("%d", len(result.Data.EnvVariables))) + output.KeyValue("Services", strconv.Itoa(len(result.Data.Services))) + output.KeyValue("Env Variables", strconv.Itoa(len(result.Data.EnvVariables))) fmt.Println("\n--- Compose Content ---") fmt.Println(result.Data.Content) if result.Data.EnvContent != "" { @@ -455,7 +457,7 @@ var getCmd = &cobra.Command{ func resolveTemplate(ctx context.Context, c *client.Client, identifier string) (*template.Template, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, fmt.Errorf("template identifier is required") + return nil, errors.New("template identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Template(trimmed)) @@ -760,7 +762,7 @@ func topTemplatesFromRankedMatches(matches []rankedTemplateMatch, limit int) []t } result := make([]template.Template, 0, limit) - for i := 0; i < limit; i++ { + for i := range limit { result = append(result, matches[i].template) } return result @@ -770,13 +772,13 @@ func minInt(values ...int) int { if len(values) == 0 { return 0 } - min := values[0] + result := values[0] for _, value := range values[1:] { - if value < min { - min = value + if value < result { + result = value } } - return min + return result } func maxInt(a, b int) int { diff --git a/cli/pkg/updater/cmd.go b/cli/pkg/updater/cmd.go index 1bfd280c73..f13aee0aaa 100644 --- a/cli/pkg/updater/cmd.go +++ b/cli/pkg/updater/cmd.go @@ -3,6 +3,7 @@ package updater import ( "encoding/json" "fmt" + "strconv" "time" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -53,8 +54,8 @@ var statusCmd = &cobra.Command{ } output.Header("Updater Status") - output.KeyValue("Updating Containers", fmt.Sprintf("%d", result.Data.UpdatingContainers)) - output.KeyValue("Updating Projects", fmt.Sprintf("%d", result.Data.UpdatingProjects)) + output.KeyValue("Updating Containers", strconv.Itoa(result.Data.UpdatingContainers)) + output.KeyValue("Updating Projects", strconv.Itoa(result.Data.UpdatingProjects)) return nil }, } @@ -93,10 +94,10 @@ var runCmd = &cobra.Command{ } output.Header("Updater Results") - output.KeyValue("Checked", fmt.Sprintf("%d", result.Data.Checked)) - output.KeyValue("Updated", fmt.Sprintf("%d", result.Data.Updated)) - output.KeyValue("Skipped", fmt.Sprintf("%d", result.Data.Skipped)) - output.KeyValue("Failed", fmt.Sprintf("%d", result.Data.Failed)) + output.KeyValue("Checked", strconv.Itoa(result.Data.Checked)) + output.KeyValue("Updated", strconv.Itoa(result.Data.Updated)) + output.KeyValue("Skipped", strconv.Itoa(result.Data.Skipped)) + output.KeyValue("Failed", strconv.Itoa(result.Data.Failed)) output.KeyValue("Duration", result.Data.Duration) return nil }, @@ -136,9 +137,9 @@ var historyCmd = &cobra.Command{ rows := make([][]string, len(result.Data)) for i, h := range result.Data { rows[i] = []string{ - fmt.Sprintf("%d", h.Checked), - fmt.Sprintf("%d", h.Updated), - fmt.Sprintf("%d", h.Failed), + strconv.Itoa(h.Checked), + strconv.Itoa(h.Updated), + strconv.Itoa(h.Failed), h.Duration, } } diff --git a/cli/pkg/volumes/cmd.go b/cli/pkg/volumes/cmd.go index b0837968ee..809fc4fcef 100644 --- a/cli/pkg/volumes/cmd.go +++ b/cli/pkg/volumes/cmd.go @@ -3,6 +3,7 @@ package volumes import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -51,7 +52,7 @@ var listCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if inUseOnlyFlag && unusedOnlyFlag { - return fmt.Errorf("--inuse and --unused cannot be used together") + return errors.New("--inuse and --unused cannot be used together") } c, err := client.NewFromConfig() if err != nil { @@ -469,7 +470,7 @@ func init() { func resolveVolume(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*volume.Volume, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, fmt.Errorf("volume identifier is required") + return nil, errors.New("volume identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Volume(c.EnvID(), trimmed)) diff --git a/types/apikey/apikey.go b/types/apikey/apikey.go index 98ba54482f..c91daede01 100644 --- a/types/apikey/apikey.go +++ b/types/apikey/apikey.go @@ -9,7 +9,7 @@ type PermissionGrant struct { EnvironmentID *string `json:"environmentId,omitempty" doc:"Environment ID to scope the grant to; omit for a global grant"` } -// Create represents the request body for creating an API key. +// CreateApiKey represents the request body for creating an API key. type CreateApiKey struct { Name string `json:"name" minLength:"1" maxLength:"255" doc:"Name of the API key" example:"My API Key"` Description *string `json:"description,omitempty" maxLength:"1000" doc:"Optional description of the API key"` @@ -36,10 +36,11 @@ type ApiKey struct { // ApiKeyCreatedDto represents a newly created API key with the full secret. type ApiKeyCreatedDto struct { ApiKey + Key string `json:"key" doc:"The full API key secret (only shown once)"` } -// Update represents the request body for updating an API key. +// UpdateApiKey represents the request body for updating an API key. type UpdateApiKey struct { Name *string `json:"name,omitempty" maxLength:"255" doc:"New name for the API key"` Description *string `json:"description,omitempty" maxLength:"1000" doc:"New description for the API key"` diff --git a/types/auth/auth.go b/types/auth/auth.go index 05a3228101..684c4f116c 100644 --- a/types/auth/auth.go +++ b/types/auth/auth.go @@ -9,12 +9,12 @@ import ( // Login represents the login request body. type Login struct { Username string `json:"username" minLength:"1" maxLength:"255" doc:"Username of the user" example:"admin"` - Password string `json:"password" minLength:"1" doc:"Password of the user"` //nolint:gosec // API schema requires password field name + Password string `json:"password" minLength:"1" doc:"Password of the user"` } // Refresh represents the token refresh request body. type Refresh struct { - RefreshToken string `json:"refreshToken" minLength:"1" doc:"Refresh token used to obtain a new access token"` //nolint:gosec // API schema requires refreshToken field name + RefreshToken string `json:"refreshToken" minLength:"1" doc:"Refresh token used to obtain a new access token"` } // PasswordChange represents the password change request body. @@ -32,7 +32,7 @@ type SessionMeta struct { // LoginResponse represents the successful login response data. type LoginResponse struct { Token string `json:"token" doc:"JWT access token"` - RefreshToken string `json:"refreshToken" doc:"Refresh token for obtaining new access tokens"` //nolint:gosec // API schema requires refreshToken field name + RefreshToken string `json:"refreshToken" doc:"Refresh token for obtaining new access tokens"` ExpiresAt time.Time `json:"expiresAt" doc:"Expiration time of the access token"` User user.User `json:"user" doc:"Authenticated user information"` } @@ -40,7 +40,7 @@ type LoginResponse struct { // TokenRefreshResponse represents the successful token refresh response data. type TokenRefreshResponse struct { Token string `json:"token" doc:"New JWT access token"` - RefreshToken string `json:"refreshToken" doc:"New refresh token"` //nolint:gosec // API schema requires refreshToken field name + RefreshToken string `json:"refreshToken" doc:"New refresh token"` ExpiresAt time.Time `json:"expiresAt" doc:"Expiration time of the new access token"` } diff --git a/types/auth/oidc.go b/types/auth/oidc.go index a7fc668a2c..83a210267d 100644 --- a/types/auth/oidc.go +++ b/types/auth/oidc.go @@ -70,7 +70,7 @@ type OidcTokenResponse struct { // AccessToken is the OAuth 2.0 access token. // // Required: true - AccessToken string `json:"access_token"` //nolint:gosec // OAuth/OIDC schema requires access_token field name + AccessToken string `json:"access_token"` // TokenType specifies the type of the access token (typically "Bearer"). // @@ -80,7 +80,7 @@ type OidcTokenResponse struct { // RefreshToken is the OAuth 2.0 refresh token. // // Required: false - RefreshToken string `json:"refresh_token,omitempty"` //nolint:gosec // OAuth/OIDC schema requires refresh_token field name + RefreshToken string `json:"refresh_token,omitempty"` // ExpiresIn is the lifetime of the access token in seconds. // @@ -223,7 +223,7 @@ type OidcCallbackResponse struct { // RefreshToken is the refresh token for obtaining new access tokens. // // Required: true - RefreshToken string `json:"refreshToken"` //nolint:gosec // API schema requires refreshToken field name + RefreshToken string `json:"refreshToken"` // ExpiresAt is the expiration time of the access token. // @@ -300,7 +300,7 @@ type OidcDeviceTokenResponse struct { // RefreshToken is the refresh token for obtaining new access tokens. // // Required: true - RefreshToken string `json:"refreshToken"` //nolint:gosec // API schema requires refreshToken field name + RefreshToken string `json:"refreshToken"` // ExpiresAt is the expiration time of the access token. // diff --git a/types/base/json.go b/types/base/json.go index 4e7ceb27a4..0ad5619107 100644 --- a/types/base/json.go +++ b/types/base/json.go @@ -9,7 +9,7 @@ import ( // in a database. It implements the sql.Valuer and sql.Scanner interfaces for // seamless database integration. // -// nolint:recvcheck +//nolint:recvcheck type JsonObject map[string]any // Value implements the driver.Valuer interface for database storage. diff --git a/types/container/history.go b/types/container/history.go index c49bdc1164..f7d22d9b4d 100644 --- a/types/container/history.go +++ b/types/container/history.go @@ -12,6 +12,7 @@ type StatsHistorySample struct { // StatsStreamPayload is the container stats websocket payload. type StatsStreamPayload struct { dockercontainer.StatsResponse + StatsHistory []StatsHistorySample `json:"statsHistory,omitempty"` CurrentHistorySample StatsHistorySample `json:"currentHistorySample"` } diff --git a/types/containerregistry/container_registry.go b/types/containerregistry/container_registry.go index 748b9a1cd9..7388e89d2d 100644 --- a/types/containerregistry/container_registry.go +++ b/types/containerregistry/container_registry.go @@ -2,7 +2,7 @@ package containerregistry import "time" -// Registry represents a container registry in API responses. +// ContainerRegistry represents a container registry in API responses. type ContainerRegistry struct { // ID of the container registry. // diff --git a/types/dockerinfo/docker_info.go b/types/dockerinfo/docker_info.go index 0996d6cd63..f193e671b6 100644 --- a/types/dockerinfo/docker_info.go +++ b/types/dockerinfo/docker_info.go @@ -3,6 +3,11 @@ package dockerinfo import "github.com/moby/moby/api/types/system" type Info struct { + // Embedded system.Info from the Docker daemon. + // + // Required: true + system.Info + // Success indicates if the Docker daemon information was successfully retrieved. // // Required: true @@ -37,9 +42,4 @@ type Info struct { // // Required: true BuildTime string `json:"buildTime"` - - // Embedded system.Info from the Docker daemon. - // - // Required: true - system.Info } diff --git a/types/environment/environment.go b/types/environment/environment.go index 6d1fa70904..3b403faaaf 100644 --- a/types/environment/environment.go +++ b/types/environment/environment.go @@ -21,7 +21,7 @@ type Create struct { // AccessToken for authentication with the environment. // // Required: false - AccessToken *string `json:"accessToken,omitempty"` //nolint:gosec // API schema requires accessToken field name + AccessToken *string `json:"accessToken,omitempty"` // UseApiKey indicates if an API key should be generated for pairing. // @@ -53,7 +53,7 @@ type Update struct { // AccessToken for authentication with the environment. // // Required: false - AccessToken *string `json:"accessToken,omitempty"` //nolint:gosec // API schema requires accessToken field name + AccessToken *string `json:"accessToken,omitempty"` // RegenerateApiKey indicates whether to regenerate the API key. // @@ -202,7 +202,7 @@ type Environment struct { // ApiKey is returned only when creating or regenerating // // Required: false - ApiKey *string `json:"apiKey,omitempty"` //nolint:gosec // API schema requires apiKey field name + ApiKey *string `json:"apiKey,omitempty"` } // AgentPairRequest is the request body for pairing with an agent. diff --git a/types/image/image.go b/types/image/image.go index aaba3eb49c..86c53b0982 100644 --- a/types/image/image.go +++ b/types/image/image.go @@ -178,7 +178,7 @@ type PruneReport struct { // combining both types into a single list and converting space reclaimed to int64. func NewPruneReport(src image.PruneReport) PruneReport { // Safely convert uint64 to int64, capping at MaxInt64 to prevent overflow - spaceReclaimed := int64(0) + var spaceReclaimed int64 if src.SpaceReclaimed > uint64(math.MaxInt64) { spaceReclaimed = math.MaxInt64 } else { @@ -537,7 +537,7 @@ func NewDetailSummary(src *image.InspectResponse) DetailSummary { if len(src.Config.ExposedPorts) > 0 { out.Config.ExposedPorts = make(map[string]struct{}, len(src.Config.ExposedPorts)) for p := range src.Config.ExposedPorts { - out.Config.ExposedPorts[string(p)] = struct{}{} + out.Config.ExposedPorts[p] = struct{}{} } } if len(src.Config.Env) > 0 { @@ -553,7 +553,8 @@ func NewDetailSummary(src *image.InspectResponse) DetailSummary { } } out.Config.WorkingDir = src.Config.WorkingDir - out.Config.ArgsEscaped = src.Config.ArgsEscaped //nolint:staticcheck // Required for Docker Windows image compatibility + //nolint:staticcheck // SA1019: deprecated field intentionally copied for Docker Windows image compatibility + out.Config.ArgsEscaped = src.Config.ArgsEscaped } out.Architecture = src.Architecture diff --git a/types/meta/template_meta.go b/types/meta/template_meta.go index aa197abd4f..0f004fee44 100644 --- a/types/meta/template_meta.go +++ b/types/meta/template_meta.go @@ -1,6 +1,6 @@ package meta -// Template represents metadata about a template. +// TemplateMeta represents metadata about a template. type TemplateMeta struct { // Version of the template. // diff --git a/types/project/marshal_utils.go b/types/project/marshal_utils.go index 69fb8cedd0..eff2fc2121 100644 --- a/types/project/marshal_utils.go +++ b/types/project/marshal_utils.go @@ -21,6 +21,7 @@ var unitBytesType = reflect.TypeFor[composetypes.UnitBytes]() // handle serviceConfig separately from the rest of the payload. type runtimeServiceJSON struct { runtimeServiceAlias + ServiceConfig json.RawMessage `json:"serviceConfig,omitempty"` } @@ -32,6 +33,7 @@ type runtimeServiceAlias RuntimeService // services array while still decoding the rest of the struct normally. type detailsJSON struct { detailsAlias + Services []json.RawMessage `json:"services,omitempty"` RuntimeServices []RuntimeService `json:"runtimeServices,omitempty"` } diff --git a/types/swarm/service.go b/types/swarm/service.go index a7c541bd98..abe16c9988 100644 --- a/types/swarm/service.go +++ b/types/swarm/service.go @@ -354,11 +354,11 @@ func NewServiceSummary(service swarm.Service, nodeNames []string, networkNameByI image = spec.TaskTemplate.ContainerSpec.Image } - ports := make([]ServicePort, 0) portSpecs := service.Endpoint.Spec.Ports if len(portSpecs) == 0 { portSpecs = service.Endpoint.Ports } + ports := make([]ServicePort, 0, len(portSpecs)) for _, port := range portSpecs { ports = append(ports, ServicePort{ Protocol: string(port.Protocol), diff --git a/types/template/template.go b/types/template/template.go index 4cdbb7a237..4d7a34f9bc 100644 --- a/types/template/template.go +++ b/types/template/template.go @@ -101,7 +101,7 @@ type RemoteRegistry struct { Templates []RemoteTemplate `json:"templates"` } -// Registry represents a local registry configuration. +// TemplateRegistry represents a local registry configuration. type TemplateRegistry struct { BaseRegistry diff --git a/types/user/user.go b/types/user/user.go index 0266ed6467..887fb99544 100644 --- a/types/user/user.go +++ b/types/user/user.go @@ -4,7 +4,7 @@ package user // Role assignments are managed separately via PUT /users/{userId}/role-assignments. type CreateUser struct { Username string `json:"username" minLength:"1" maxLength:"255" doc:"Username of the user" example:"johndoe"` - Password string `json:"password" minLength:"8" doc:"Password of the user"` //nolint:gosec // API schema requires password field name + Password string `json:"password" minLength:"8" doc:"Password of the user"` DisplayName *string `json:"displayName,omitempty" maxLength:"255" doc:"Display name of the user" example:"John Doe"` Email *string `json:"email,omitempty" doc:"Email address of the user" example:"john@example.com"` Locale *string `json:"locale,omitempty" doc:"Locale preference of the user" example:"en-US"` @@ -17,7 +17,7 @@ type UpdateUser struct { DisplayName *string `json:"displayName,omitempty" maxLength:"255" doc:"Display name of the user"` Email *string `json:"email,omitempty" doc:"Email address of the user"` Locale *string `json:"locale,omitempty" doc:"Locale preference of the user"` - Password *string `json:"password,omitempty" minLength:"8" doc:"New password for the user"` //nolint:gosec // API schema requires password field name + Password *string `json:"password,omitempty" minLength:"8" doc:"New password for the user"` } // RoleAssignmentSummary is a compact form of a user's role assignment used diff --git a/types/volume/browse.go b/types/volume/browse.go index d690a1f1d3..b6f2f470e9 100644 --- a/types/volume/browse.go +++ b/types/volume/browse.go @@ -15,6 +15,7 @@ type FileEntry struct { type FileMetadata struct { FileEntry + MimeType string `json:"mimeType" doc:"MIME type of the file"` IsText bool `json:"isText" doc:"Whether the file is a text file"` IsBinary bool `json:"isBinary" doc:"Whether the file is a binary file"` From 51104af36da6069c9cafe2ecbd49d25f5fc9a2a4 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 14:17:53 -0500 Subject: [PATCH 26/40] refactor: generate services and jobs from google/wire (#2775) --- Justfile | 26 +- backend/api/api.go | 205 ++------ backend/api/diagnostics.go | 44 -- backend/api/handlers/diagnostics.go | 73 +++ backend/api/ws/diagnostics.go | 150 ++++++ backend/api/ws/handler.go | 45 +- backend/go.mod | 5 + backend/go.sum | 11 + backend/internal/bootstrap/bootstrap.go | 10 +- backend/internal/bootstrap/bootstrap_test.go | 9 +- .../bootstrap/buildables_router_bootstrap.go | 3 +- backend/internal/bootstrap/edge_bootstrap.go | 3 +- backend/internal/bootstrap/jobs_bootstrap.go | 87 ++-- .../bootstrap/playwright_router_bootstrap.go | 5 +- .../internal/bootstrap/router_bootstrap.go | 77 +-- .../internal/bootstrap/services_bootstrap.go | 116 ----- backend/internal/di/providers.go | 182 +++++++ backend/internal/di/wire.go | 115 +++++ backend/internal/di/wire_gen.go | 191 +++++++ .../internal/services/diagnostics_service.go | 85 ++++ .../services/diagnostics_service_test.go | 22 + backend/pkg/authz/catalog.go | 3 + backend/pkg/authz/permissions.go | 5 + backend/pkg/libarcane/logstream/logstream.go | 152 ++++++ frontend/messages/en.json | 91 ++++ .../cache/plugins/2sy648wh9sugi | 308 ++++++++++- frontend/src/lib/config/navigation-config.ts | 7 + .../src/lib/services/diagnostics-service.ts | 57 +++ frontend/src/lib/types/diagnostics.ts | 82 +++ frontend/src/lib/utils/ws.ts | 47 ++ .../(app)/settings/diagnostics/+page.svelte | 480 ++++++++++++++++++ .../diagnostics/diagnostic-log-panel.svelte | 162 ++++++ .../diagnostics/diagnostic-stat.svelte | 29 ++ types/system/diagnostics.go | 102 ++++ 34 files changed, 2478 insertions(+), 511 deletions(-) delete mode 100644 backend/api/diagnostics.go create mode 100644 backend/api/handlers/diagnostics.go create mode 100644 backend/api/ws/diagnostics.go delete mode 100644 backend/internal/bootstrap/services_bootstrap.go create mode 100644 backend/internal/di/providers.go create mode 100644 backend/internal/di/wire.go create mode 100644 backend/internal/di/wire_gen.go create mode 100644 backend/internal/services/diagnostics_service.go create mode 100644 backend/internal/services/diagnostics_service_test.go create mode 100644 backend/pkg/libarcane/logstream/logstream.go create mode 100644 frontend/src/lib/services/diagnostics-service.ts create mode 100644 frontend/src/lib/types/diagnostics.ts create mode 100644 frontend/src/routes/(app)/settings/diagnostics/+page.svelte create mode 100644 frontend/src/routes/(app)/settings/diagnostics/diagnostic-log-panel.svelte create mode 100644 frontend/src/routes/(app)/settings/diagnostics/diagnostic-stat.svelte create mode 100644 types/system/diagnostics.go diff --git a/Justfile b/Justfile index 1baf3e8847..993447d78f 100644 --- a/Justfile +++ b/Justfile @@ -443,35 +443,35 @@ deps action="update" target="all": # ----------------------------------------------------------------------------- # Run go mod tidy in backend module -[group('go-modules')] +[group('gomod')] _gomod-tidy-backend: cd backend && go mod tidy # Run go mod tidy in CLI module -[group('go-modules')] +[group('gomod')] _gomod-tidy-cli: cd cli && go mod tidy # Run go mod tidy in types module -[group('go-modules')] +[group('gomod')] _gomod-tidy-types: cd types && go mod tidy # Run go mod tidy in all Go modules -[group('go-modules')] +[group('gomod')] _gomod-tidy-go: _gomod-tidy-backend _gomod-tidy-cli _gomod-tidy-types -[group('go-modules')] +[group('gomod')] _gomod-tidy-all: @just _gomod-tidy-go go work sync -[group('go-modules')] +[group('gomod')] _gomod-sync-all: go work sync # Go module targets. Valid: "tidy [backend|cli|types|go|all]", "sync all". -[group('go-modules')] +[group('gomod')] gomod action="tidy" target="all": @just "_gomod-{{ action }}-{{ target }}" @@ -481,9 +481,19 @@ gomod action="tidy" target="all": # Generate edge tunnel protobuf/gRPC code. [group('codegen')] -proto-backend: +_generate-proto: cd {{ edge_proto_dir }} && go run github.com/bufbuild/buf/cmd/buf@latest generate +# Generate Wire dependency injection code. +[group('codegen')] +_generate-wire: + cd backend && go tool wire ./... + +# Generate targets. Valid: "proto", "wire". +[group('codegen')] +generate target: + @just "_generate-{{ target }}" + # Generate the docs config schema JSON. [group('docs')] _docs-config output="" source_root=".": diff --git a/backend/api/api.go b/backend/api/api.go index 0be9a4a0d4..c195f2403a 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -11,7 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/api/handlers" "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/config" - "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/labstack/echo/v4" ) @@ -137,51 +137,8 @@ func capitalizeFirst(s string) string { return strings.ToUpper(s[:1]) + s[1:] } -// Services holds all service dependencies needed by Huma handlers. -type Services struct { - AppContext context.Context - User *services.UserService - Auth *services.AuthService - Oidc *services.OidcService - ApiKey *services.ApiKeyService - Federated *services.FederatedCredentialService - AppImages *services.ApplicationImagesService - Project *services.ProjectService - Event *services.EventService - Activity *services.ActivityService - Version *services.VersionService - Environment *services.EnvironmentService - Settings *services.SettingsService - JobSchedule *services.JobService - SettingsSearch *services.SettingsSearchService - ContainerRegistry *services.ContainerRegistryService - Template *services.TemplateService - Docker *services.DockerClientService - Image *services.ImageService - ImageUpdate *services.ImageUpdateService - Build *services.BuildService - BuildWorkspace *services.BuildWorkspaceService - Volume *services.VolumeService - Container *services.ContainerService - Network *services.NetworkService - Port *services.PortService - Swarm *services.SwarmService - Notification *services.NotificationService - Updater *services.UpdaterService - CustomizeSearch *services.CustomizeSearchService - System *services.SystemService - SystemUpgrade *services.SystemUpgradeService - GitRepository *services.GitRepositoryService - GitOpsSync *services.GitOpsSyncService - Webhook *services.WebhookService - Vulnerability *services.VulnerabilityService - Dashboard *services.DashboardService - Role *services.RoleService - Config *config.Config -} - // SetupAPI creates and configures the Huma API attached to the Echo router. -func SetupAPI(e *echo.Echo, apiGroup *echo.Group, cfg *config.Config, svc *Services) huma.API { +func SetupAPI(e *echo.Echo, apiGroup *echo.Group, appCtx handlers.ActivityAppContext, cfg *config.Config, svc *di.Services) huma.API { humaConfig := huma.DefaultConfig("Arcane API", config.Version) humaConfig.Info.Description = "Modern Docker Management, Designed for Everyone" @@ -230,7 +187,7 @@ func SetupAPI(e *echo.Echo, apiGroup *echo.Group, cfg *config.Config, svc *Servi api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, svc.Role, svc.Environment, cfg)) // Register all Huma handlers - registerHandlers(api, svc) + registerHandlersInternal(api, svc, appCtx, cfg) // Register Scalar API docs endpoint with dark mode registerScalarDocs(apiGroup) @@ -306,128 +263,52 @@ func SetupAPIForSpec() huma.API { api := humaecho.NewWithGroup(e, apiGroup, humaConfig) // Register handlers with nil services (just for schema) - registerHandlers(api, nil) + registerHandlersInternal(api, nil, handlers.NewActivityAppContext(context.Background()), nil) return api } // registerHandlers registers all Huma-based API handlers. // Add new handlers here as they are migrated from Gin. -func registerHandlers(api huma.API, svc *Services) { - var userSvc *services.UserService - var authSvc *services.AuthService - var oidcSvc *services.OidcService - var apiKeySvc *services.ApiKeyService - var federatedSvc *services.FederatedCredentialService - var appImagesSvc *services.ApplicationImagesService - var projectSvc *services.ProjectService - var eventSvc *services.EventService - var activitySvc *services.ActivityService - var versionSvc *services.VersionService - var environmentSvc *services.EnvironmentService - var settingsSvc *services.SettingsService - var jobScheduleSvc *services.JobService - var settingsSearchSvc *services.SettingsSearchService - var containerRegistrySvc *services.ContainerRegistryService - var templateSvc *services.TemplateService - var dockerSvc *services.DockerClientService - var imageSvc *services.ImageService - var imageUpdateSvc *services.ImageUpdateService - var buildSvc *services.BuildService - var buildWorkspaceSvc *services.BuildWorkspaceService - var volumeSvc *services.VolumeService - var containerSvc *services.ContainerService - var networkSvc *services.NetworkService - var portSvc *services.PortService - var swarmSvc *services.SwarmService - var notificationSvc *services.NotificationService - var updaterSvc *services.UpdaterService - var customizeSearchSvc *services.CustomizeSearchService - var systemSvc *services.SystemService - var systemUpgradeSvc *services.SystemUpgradeService - var gitRepositorySvc *services.GitRepositoryService - var gitOpsSyncSvc *services.GitOpsSyncService - var webhookSvc *services.WebhookService - var vulnerabilitySvc *services.VulnerabilityService - var dashboardSvc *services.DashboardService - var roleSvc *services.RoleService - var appCtx context.Context - var cfg *config.Config - - if svc != nil { - appCtx = svc.AppContext - userSvc = svc.User - authSvc = svc.Auth - oidcSvc = svc.Oidc - apiKeySvc = svc.ApiKey - federatedSvc = svc.Federated - appImagesSvc = svc.AppImages - projectSvc = svc.Project - eventSvc = svc.Event - activitySvc = svc.Activity - versionSvc = svc.Version - environmentSvc = svc.Environment - settingsSvc = svc.Settings - jobScheduleSvc = svc.JobSchedule - settingsSearchSvc = svc.SettingsSearch - containerRegistrySvc = svc.ContainerRegistry - templateSvc = svc.Template - dockerSvc = svc.Docker - imageSvc = svc.Image - imageUpdateSvc = svc.ImageUpdate - buildSvc = svc.Build - buildWorkspaceSvc = svc.BuildWorkspace - volumeSvc = svc.Volume - containerSvc = svc.Container - networkSvc = svc.Network - portSvc = svc.Port - swarmSvc = svc.Swarm - notificationSvc = svc.Notification - updaterSvc = svc.Updater - customizeSearchSvc = svc.CustomizeSearch - systemSvc = svc.System - systemUpgradeSvc = svc.SystemUpgrade - gitRepositorySvc = svc.GitRepository - gitOpsSyncSvc = svc.GitOpsSync - webhookSvc = svc.Webhook - vulnerabilitySvc = svc.Vulnerability - dashboardSvc = svc.Dashboard - roleSvc = svc.Role - cfg = svc.Config +func registerHandlersInternal(api huma.API, svc *di.Services, handlerAppCtx handlers.ActivityAppContext, cfg *config.Config) { + // svc is nil during OpenAPI spec generation (SetupAPIForSpec); an empty + // container keeps every field a true-nil pointer so handler nil-guards hold. + if svc == nil { + svc = &di.Services{} } - handlerAppCtx := handlers.NewActivityAppContext(appCtx) handlers.RegisterHealth(api) - handlers.RegisterAuth(api, userSvc, authSvc, oidcSvc) - handlers.RegisterApiKeys(api, apiKeySvc) - handlers.RegisterFederatedCredentials(api, federatedSvc) - handlers.RegisterRoles(api, roleSvc) - handlers.RegisterAppImages(api, appImagesSvc) - handlers.RegisterUsers(api, userSvc, authSvc) - handlers.RegisterProjects(api, projectSvc, activitySvc, handlerAppCtx) - handlers.RegisterVersion(api, versionSvc) - handlers.RegisterEvents(api, eventSvc, apiKeySvc) - handlers.RegisterActivities(api, activitySvc, environmentSvc) - handlers.RegisterOidc(api, authSvc, oidcSvc, roleSvc, userSvc, cfg) - handlers.RegisterEnvironments(api, environmentSvc, settingsSvc, apiKeySvc, eventSvc, cfg) - handlers.RegisterContainerRegistries(api, containerRegistrySvc, environmentSvc) - handlers.RegisterTemplates(api, templateSvc, environmentSvc) - handlers.RegisterImages(api, dockerSvc, imageSvc, imageUpdateSvc, settingsSvc, buildSvc, activitySvc, handlerAppCtx) - handlers.RegisterBuildWorkspaces(api, buildWorkspaceSvc) - handlers.RegisterImageUpdates(api, imageUpdateSvc, imageSvc, handlerAppCtx) - handlers.RegisterSettings(api, settingsSvc, settingsSearchSvc, environmentSvc, cfg) - handlers.RegisterJobSchedules(api, jobScheduleSvc, environmentSvc) - handlers.RegisterVolumes(api, dockerSvc, volumeSvc, activitySvc, handlerAppCtx) - handlers.RegisterContainers(api, containerSvc, dockerSvc, settingsSvc, activitySvc, handlerAppCtx) - handlers.RegisterPorts(api, portSvc) - handlers.RegisterNetworks(api, networkSvc, dockerSvc, activitySvc, handlerAppCtx) - handlers.RegisterSwarm(api, swarmSvc, environmentSvc, eventSvc, cfg) - handlers.RegisterNotifications(api, notificationSvc, cfg) - handlers.RegisterUpdater(api, updaterSvc, handlerAppCtx) - handlers.RegisterCustomize(api, customizeSearchSvc) - handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg, activitySvc, handlerAppCtx) - handlers.RegisterGitRepositories(api, gitRepositorySvc) - handlers.RegisterGitOpsSyncs(api, gitOpsSyncSvc) - handlers.RegisterWebhooks(api, webhookSvc) - handlers.RegisterVulnerability(api, vulnerabilitySvc, handlerAppCtx) - handlers.RegisterDashboard(api, dashboardSvc) + handlers.RegisterAuth(api, svc.User, svc.Auth, svc.Oidc) + handlers.RegisterApiKeys(api, svc.ApiKey) + handlers.RegisterFederatedCredentials(api, svc.Federated) + handlers.RegisterRoles(api, svc.Role) + handlers.RegisterAppImages(api, svc.AppImages) + handlers.RegisterUsers(api, svc.User, svc.Auth) + handlers.RegisterProjects(api, svc.Project, svc.Activity, handlerAppCtx) + handlers.RegisterVersion(api, svc.Version) + handlers.RegisterEvents(api, svc.Event, svc.ApiKey) + handlers.RegisterActivities(api, svc.Activity, svc.Environment) + handlers.RegisterOidc(api, svc.Auth, svc.Oidc, svc.Role, svc.User, cfg) + handlers.RegisterEnvironments(api, svc.Environment, svc.Settings, svc.ApiKey, svc.Event, cfg) + handlers.RegisterContainerRegistries(api, svc.ContainerRegistry, svc.Environment) + handlers.RegisterTemplates(api, svc.Template, svc.Environment) + handlers.RegisterImages(api, svc.Docker, svc.Image, svc.ImageUpdate, svc.Settings, svc.Build, svc.Activity, handlerAppCtx) + handlers.RegisterBuildWorkspaces(api, svc.BuildWorkspace) + handlers.RegisterImageUpdates(api, svc.ImageUpdate, svc.Image, handlerAppCtx) + handlers.RegisterSettings(api, svc.Settings, svc.SettingsSearch, svc.Environment, cfg) + handlers.RegisterJobSchedules(api, svc.JobSchedule, svc.Environment) + handlers.RegisterVolumes(api, svc.Docker, svc.Volume, svc.Activity, handlerAppCtx) + handlers.RegisterContainers(api, svc.Container, svc.Docker, svc.Settings, svc.Activity, handlerAppCtx) + handlers.RegisterPorts(api, svc.Port) + handlers.RegisterNetworks(api, svc.Network, svc.Docker, svc.Activity, handlerAppCtx) + handlers.RegisterSwarm(api, svc.Swarm, svc.Environment, svc.Event, cfg) + handlers.RegisterNotifications(api, svc.Notification, cfg) + handlers.RegisterUpdater(api, svc.Updater, handlerAppCtx) + handlers.RegisterCustomize(api, svc.CustomizeSearch) + handlers.RegisterSystem(api, svc.Docker, svc.System, svc.SystemUpgrade, cfg, svc.Activity, handlerAppCtx) + handlers.RegisterDiagnostics(api, svc.Diagnostics) + handlers.RegisterGitRepositories(api, svc.GitRepository) + handlers.RegisterGitOpsSyncs(api, svc.GitOpsSync) + handlers.RegisterWebhooks(api, svc.Webhook) + handlers.RegisterVulnerability(api, svc.Vulnerability, handlerAppCtx) + handlers.RegisterDashboard(api, svc.Dashboard) } diff --git a/backend/api/diagnostics.go b/backend/api/diagnostics.go deleted file mode 100644 index 6d9f2acbb2..0000000000 --- a/backend/api/diagnostics.go +++ /dev/null @@ -1,44 +0,0 @@ -package api - -import ( - "net/http" - "runtime" - "time" - - "github.com/getarcaneapp/arcane/backend/api/ws" - "github.com/getarcaneapp/arcane/backend/internal/middleware" - "github.com/getarcaneapp/arcane/backend/pkg/authz" - wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" - "github.com/labstack/echo/v4" -) - -type DiagnosticsHandler struct { - wsMetrics *ws.WebSocketMetrics -} - -func RegisterDiagnosticsRoutes(group *echo.Group, authMiddleware *middleware.AuthMiddleware, wsMetrics *ws.WebSocketMetrics) { - h := &DiagnosticsHandler{wsMetrics: wsMetrics} - - diagnostics := group.Group("/diagnostics", authMiddleware.Add()) - diagnostics.GET("/ws", h.WebSocketDiagnostics) -} - -func (h *DiagnosticsHandler) WebSocketDiagnostics(c echo.Context) error { - ps, _ := c.Get("userPermissions").(*authz.PermissionSet) - if !ps.IsGlobalAdmin() { - return c.JSON(http.StatusForbidden, map[string]any{"error": "Admin access required"}) - } - - metrics := h.wsMetrics.Snapshot() - connections := h.wsMetrics.Connections() - - return c.JSON(http.StatusOK, map[string]any{ - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "goroutines": runtime.NumGoroutine(), - "wsWorkerGoroutine": wshub.CountWorkerGoroutines(), - "gomaxprocs": runtime.GOMAXPROCS(0), - "goVersion": runtime.Version(), - "activeConnections": metrics, - "connections": connections, - }) -} diff --git a/backend/api/handlers/diagnostics.go b/backend/api/handlers/diagnostics.go new file mode 100644 index 0000000000..0b815eb51a --- /dev/null +++ b/backend/api/handlers/diagnostics.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/api/ws" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/logstream" + "github.com/getarcaneapp/arcane/types/system" +) + +// DiagnosticsHandler serves the REST diagnostics endpoints. The live WebSocket +// streams and pprof routes live in the api/ws package alongside the other +// streaming endpoints; the snapshot is assembled there too (ws.BuildDiagnostics). +type DiagnosticsHandler struct { + diag *services.DiagnosticsService +} + +type DiagnosticsInput struct{} + +type GetDiagnosticsOutput struct { + Body system.Diagnostics +} + +type GetDiagnosticsLogsOutput struct { + Body []system.LogEntry +} + +// RegisterDiagnostics registers the Huma diagnostics REST endpoints. +func RegisterDiagnostics(api huma.API, diag *services.DiagnosticsService) { + h := &DiagnosticsHandler{diag: diag} + + huma.Register(api, huma.Operation{ + OperationID: "get-diagnostics", + Method: http.MethodGet, + Path: "/diagnostics", + Summary: "Get runtime diagnostics", + Description: "Returns Go runtime, memory, garbage-collector, and WebSocket connection statistics.", + Tags: []string{"Diagnostics"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermDiagnosticsRead), + }, h.GetDiagnostics) + + huma.Register(api, huma.Operation{ + OperationID: "get-diagnostics-logs", + Method: http.MethodGet, + Path: "/diagnostics/logs", + Summary: "Get recent backend logs", + Description: "Returns the most recent buffered backend log entries (oldest first).", + Tags: []string{"Diagnostics"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermDiagnosticsRead), + }, h.GetRecentLogs) +} + +func (h *DiagnosticsHandler) GetDiagnostics(_ context.Context, _ *DiagnosticsInput) (*GetDiagnosticsOutput, error) { + return &GetDiagnosticsOutput{Body: ws.BuildDiagnostics(h.diag)}, nil +} + +func (h *DiagnosticsHandler) GetRecentLogs(_ context.Context, _ *DiagnosticsInput) (*GetDiagnosticsLogsOutput, error) { + return &GetDiagnosticsLogsOutput{Body: logstream.Default().Recent()}, nil +} diff --git a/backend/api/ws/diagnostics.go b/backend/api/ws/diagnostics.go new file mode 100644 index 0000000000..9d74e04a11 --- /dev/null +++ b/backend/api/ws/diagnostics.go @@ -0,0 +1,150 @@ +package ws + +import ( + "encoding/json" + "net/http" + "net/http/pprof" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/logstream" + wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" + systemtypes "github.com/getarcaneapp/arcane/types/system" + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" +) + +// diagnosticsStreamInterval is how often the live diagnostics stream pushes a snapshot. +const diagnosticsStreamInterval = 2 * time.Second + +// BuildDiagnostics assembles a full diagnostics snapshot: runtime/memory/GC from +// the DiagnosticsService plus this package's WebSocket metrics and worker-goroutine +// count. Shared by the REST endpoint (via handlers) and the live WebSocket stream. +func BuildDiagnostics(diag *services.DiagnosticsService) systemtypes.Diagnostics { + d := systemtypes.Diagnostics{Timestamp: time.Now().UTC()} + if diag != nil { + d.Runtime, d.Memory, d.GC = diag.Collect() + } + d.Runtime.WSWorkerGoroutines = wshub.CountWorkerGoroutines() + d.WebSocket = systemtypes.WebSocketDiagnostics{ + Snapshot: defaultWebSocketMetrics.Snapshot(), + Connections: defaultWebSocketMetrics.Connections(), + } + return d +} + +// registerDiagnosticsRoutesInternal wires the global diagnostics WebSocket streams and the +// net/http/pprof debug endpoints. Streams require the diagnostics permission; +// pprof keeps the stricter admin-required gate. Called from NewWebSocketHandler. +func (h *WebSocketHandler) registerDiagnosticsRoutesInternal(group *echo.Group, authMiddleware *middleware.AuthMiddleware) { + diag := group.Group("/diagnostics", + authMiddleware.WithAdminNotRequired().Add(), + middleware.RequirePermission(authz.PermDiagnosticsRead), + ) + diag.GET("/stream", h.DiagnosticsStream) + diag.GET("/logs/stream", h.ServerLogsStream) + + pprofGroup := group.Group("/debug/pprof", authMiddleware.WithAdminRequired().Add()) + pprofGroup.GET("", echo.WrapHandler(http.HandlerFunc(pprof.Index))) + pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index))) + pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) + pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) + pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) + pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) + pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace))) + pprofGroup.GET("/:name", echo.WrapHandler(http.HandlerFunc(pprof.Index))) +} + +// DiagnosticsStream pushes a fresh diagnostics snapshot on connect and then every +// diagnosticsStreamInterval until the client disconnects. +func (h *WebSocketHandler) DiagnosticsStream(c echo.Context) error { + conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) + if err != nil { + return nil + } + defer conn.Close() + + done := diagnosticsReadLoopInternal(conn) + write := func() bool { + b, marshalErr := json.Marshal(BuildDiagnostics(h.diagnosticsService)) + if marshalErr != nil { + return true + } + return conn.WriteMessage(websocket.TextMessage, b) == nil + } + + if !write() { + return nil + } + ticker := time.NewTicker(diagnosticsStreamInterval) + defer ticker.Stop() + for { + select { + case <-done: + return nil + case <-ticker.C: + if !write() { + return nil + } + } + } +} + +// ServerLogsStream replays the recent backend log backlog then streams new entries live. +func (h *WebSocketHandler) ServerLogsStream(c echo.Context) error { + conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) + if err != nil { + return nil + } + defer conn.Close() + + // Subscribe before replaying the backlog so no entry is missed in the gap; at + // worst the newest backlog entry is delivered twice, which is harmless. + ch, cancel := logstream.Default().Subscribe() + defer cancel() + + done := diagnosticsReadLoopInternal(conn) + write := func(e systemtypes.LogEntry) bool { + b, marshalErr := json.Marshal(e) + if marshalErr != nil { + return true + } + return conn.WriteMessage(websocket.TextMessage, b) == nil + } + + for _, e := range logstream.Default().Recent() { + if !write(e) { + return nil + } + } + for { + select { + case <-done: + return nil + case e, ok := <-ch: + if !ok { + return nil + } + if !write(e) { + return nil + } + } + } +} + +// diagnosticsReadLoopInternal drains incoming frames; the returned channel closes +// when the peer disconnects, signaling the writer loop to stop. +func diagnosticsReadLoopInternal(conn *websocket.Conn) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + return done +} diff --git a/backend/api/ws/handler.go b/backend/api/ws/handler.go index 8731d090c9..25151255f8 100644 --- a/backend/api/ws/handler.go +++ b/backend/api/ws/handler.go @@ -134,11 +134,6 @@ func (m *WebSocketMetrics) applyDelta(kind string, delta int64) { var defaultWebSocketMetrics = NewWebSocketMetrics() -// DefaultWebSocketMetrics returns the package-level WebSocketMetrics singleton. -func DefaultWebSocketMetrics() *WebSocketMetrics { - return defaultWebSocketMetrics -} - // ============================================================================ // WebSocket Handler // ============================================================================ @@ -146,16 +141,17 @@ func DefaultWebSocketMetrics() *WebSocketMetrics { // WebSocketHandler consolidates all WebSocket and streaming endpoints. // REST endpoints are handled by Huma handlers. type WebSocketHandler struct { - projectService *services.ProjectService - containerService *services.ContainerService - swarmService *services.SwarmService - systemService *services.SystemService - wsUpgrader websocket.Upgrader - wsMetrics *WebSocketMetrics - activeConnections sync.Map - logStreamsMu sync.Mutex - logStreams map[string]*wsLogStream - cpuCache struct { + projectService *services.ProjectService + containerService *services.ContainerService + swarmService *services.SwarmService + systemService *services.SystemService + diagnosticsService *services.DiagnosticsService + wsUpgrader websocket.Upgrader + wsMetrics *WebSocketMetrics + activeConnections sync.Map + logStreamsMu sync.Mutex + logStreams map[string]*wsLogStream + cpuCache struct { sync.RWMutex value float64 @@ -321,18 +317,20 @@ func NewWebSocketHandler( containerService *services.ContainerService, swarmService *services.SwarmService, systemService *services.SystemService, + diagnosticsService *services.DiagnosticsService, authMiddleware *middleware.AuthMiddleware, cfg *config.Config, ) { handler := &WebSocketHandler{ - projectService: projectService, - containerService: containerService, - swarmService: swarmService, - systemService: systemService, - wsMetrics: defaultWebSocketMetrics, - logStreams: make(map[string]*wsLogStream), - cgroupCache: system.NewCgroupCache(cgroupCacheTTL), - gpuMonitor: system.NewGPUMonitor(cfg.GPUMonitoringEnabled, cfg.GPUType), + projectService: projectService, + containerService: containerService, + swarmService: swarmService, + systemService: systemService, + diagnosticsService: diagnosticsService, + wsMetrics: defaultWebSocketMetrics, + logStreams: make(map[string]*wsLogStream), + cgroupCache: system.NewCgroupCache(cgroupCacheTTL), + gpuMonitor: system.NewGPUMonitor(cfg.GPUMonitoringEnabled, cfg.GPUType), wsUpgrader: websocket.Upgrader{ CheckOrigin: httputil.ValidateWebSocketOrigin(cfg.GetAppURL()), ReadBufferSize: 32 * 1024, @@ -347,6 +345,7 @@ func NewWebSocketHandler( wsGroup.GET("/containers/:containerId/terminal", handler.ContainerExec, middleware.RequirePermission(authz.PermContainersExec)) wsGroup.GET("/swarm/services/:serviceId/logs", handler.ServiceLogs, middleware.RequirePermission(authz.PermSwarmServicesLogs)) wsGroup.GET("/system/stats", handler.SystemStats, middleware.RequirePermission(authz.PermSystemRead)) + handler.registerDiagnosticsRoutesInternal(group, authMiddleware) } // ============================================================================ diff --git a/backend/go.mod b/backend/go.mod index e2673d2353..accea47797 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -31,6 +31,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 + github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-uuid v1.0.3 github.com/jinzhu/copier v0.4.0 @@ -128,6 +129,7 @@ require ( github.com/google/go-github/v39 v39.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -219,6 +221,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/ini.v1 v1.67.2 // indirect @@ -231,3 +234,5 @@ require ( modernc.org/sqlite v1.50.1 // indirect tags.cncf.io/container-device-interface v1.1.0 // indirect ) + +tool github.com/google/wire/cmd/wire diff --git a/backend/go.sum b/backend/go.sum index 76af66d44f..76b8e55ca1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -277,8 +277,12 @@ github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -558,7 +562,9 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0 h1:cuXaPAfIoJKsYjBjPSb2nKZEmgM43zVr25l37IxhKME= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0/go.mod h1:BuzhPofpCzlDi/Q/Xjg54M4/3oWqqyDe2Zeq7A2I0QE= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= @@ -570,10 +576,15 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 62279c4090..7a975d104a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -17,10 +17,12 @@ import ( "github.com/moby/moby/client" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/services" libcrypto "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/logstream" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/startup" "github.com/getarcaneapp/arcane/backend/pkg/scheduler" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -45,6 +47,9 @@ func Bootstrap(ctx context.Context) error { cfg.DockerConfig = runtimeIdentityCfg.DockerConfig SetupSlogLogger(cfg) + // Tee all slog output into the in-memory ring buffer that powers the + // diagnostics live log tail. + slog.SetDefault(slog.New(logstream.NewSlogHandler(slog.Default().Handler(), logstream.Default()))) ConfigureGormLogger(cfg) slog.InfoContext(ctx, "Arcane is starting...", "version", config.Version) slog.InfoContext(ctx, "Arcane Identity Configuration", "PUID", os.Getuid(), "PGID", os.Getgid()) @@ -70,10 +75,11 @@ func Bootstrap(ctx context.Context) error { httpClient := newConfiguredHTTPClient(cfg) - appServices, dockerClientService, err := initializeServices(appCtx, db, cfg, httpClient) + appServices, err := di.InitializeServices(appCtx, db, cfg, httpClient) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } + dockerClientService := appServices.Docker defer dockerClientService.Close() defer func(ctx context.Context) { baseCtx := context.WithoutCancel(ctx) @@ -111,7 +117,7 @@ func newConfiguredHTTPClient(cfg *config.Config) *http.Client { return httputils.NewHTTPClient() } -func initializeStartupState(appCtx context.Context, cfg *config.Config, appServices *Services, dockerClientService *services.DockerClientService, httpClient *http.Client) { +func initializeStartupState(appCtx context.Context, cfg *config.Config, appServices *di.Services, dockerClientService *services.DockerClientService, httpClient *http.Client) { if appServices.Volume != nil { startup.CleanupOrphanedVolumeHelpers(appCtx, appServices.Volume.CleanupOrphanedVolumeHelpers) } diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go index 330aac4fde..5924efc2d3 100644 --- a/backend/internal/bootstrap/bootstrap_test.go +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -12,6 +12,8 @@ import ( "time" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" + "github.com/getarcaneapp/arcane/backend/internal/middleware" libcrypto "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1" "github.com/stretchr/testify/assert" @@ -149,10 +151,13 @@ func TestConfigureHTTPProtocolsInternal(t *testing.T) { } func TestHTTP2APIResponsesDoNotUseAPIGzipInternal(t *testing.T) { - router, _ := setupRouter(context.Background(), &config.Config{ + cfg := &config.Config{ AppUrl: "http://localhost:3552", Environment: config.AppEnvironmentTest, - }, &Services{}) + } + router, _ := setupRouter(context.Background(), cfg, &di.Services{ + AuthMiddleware: middleware.NewAuthMiddleware(nil, cfg), + }) handler, protocols := configureHTTPProtocolsInternal(false, router) listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/backend/internal/bootstrap/buildables_router_bootstrap.go b/backend/internal/bootstrap/buildables_router_bootstrap.go index a1d3380526..082c09165d 100644 --- a/backend/internal/bootstrap/buildables_router_bootstrap.go +++ b/backend/internal/bootstrap/buildables_router_bootstrap.go @@ -4,11 +4,12 @@ package bootstrap import ( "github.com/getarcaneapp/arcane/backend/api" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/labstack/echo/v4" ) func init() { - registerBuildableRoutes = append(registerBuildableRoutes, func(apiGroup *echo.Group, svc *Services) { + registerBuildableRoutes = append(registerBuildableRoutes, func(apiGroup *echo.Group, svc *di.Services) { api.SetupBuildablesRoutes(apiGroup, svc.Auth) }) } diff --git a/backend/internal/bootstrap/edge_bootstrap.go b/backend/internal/bootstrap/edge_bootstrap.go index 0df3cdca52..f8493cfa6c 100644 --- a/backend/internal/bootstrap/edge_bootstrap.go +++ b/backend/internal/bootstrap/edge_bootstrap.go @@ -8,6 +8,7 @@ import ( "log/slog" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" @@ -22,7 +23,7 @@ func registerEdgeTunnelRoutes( ctx context.Context, cfg *config.Config, apiGroup *echo.Group, - appServices *Services, + appServices *di.Services, ) *edge.TunnelServer { // Resolver that validates API key and returns the environment ID resolver := func(ctx context.Context, token string) (string, error) { diff --git a/backend/internal/bootstrap/jobs_bootstrap.go b/backend/internal/bootstrap/jobs_bootstrap.go index b764b9776f..c709242fd6 100644 --- a/backend/internal/bootstrap/jobs_bootstrap.go +++ b/backend/internal/bootstrap/jobs_bootstrap.go @@ -7,102 +7,83 @@ import ( "net/http" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" pkg_scheduler "github.com/getarcaneapp/arcane/backend/pkg/scheduler" ) -func registerJobs(appCtx context.Context, newScheduler *pkg_scheduler.JobScheduler, appServices *Services, appConfig *config.Config) { - autoUpdateJob := pkg_scheduler.NewAutoUpdateJob(appServices.Updater, appServices.Settings) - newScheduler.RegisterJob(autoUpdateJob) +func registerJobs(appCtx context.Context, newScheduler *pkg_scheduler.JobScheduler, appServices *di.Services, appConfig *config.Config) { + // wire constructs every job from the built services; bootstrap owns registration, + // the agent-mode gating, the startup heartbeat, and the settings callbacks. + jobs := di.InitializeJobs(appCtx, appConfig, appServices) - imagePollingJob := pkg_scheduler.NewImagePollingJob(appServices.ImageUpdate, appServices.Settings, appServices.Environment) - newScheduler.RegisterJob(imagePollingJob) - - environmentHealthJob := pkg_scheduler.NewEnvironmentHealthJob(appServices.Environment, appServices.Settings) + newScheduler.RegisterJob(jobs.AutoUpdate) + newScheduler.RegisterJob(jobs.ImagePolling) if !appConfig.AgentMode { - newScheduler.RegisterJob(environmentHealthJob) + newScheduler.RegisterJob(jobs.EnvironmentHealth) } - - dockerClientRefreshJob := pkg_scheduler.NewDockerClientRefreshJob(appServices.Docker, appServices.Settings) - newScheduler.RegisterJob(dockerClientRefreshJob) - - analyticsJob := pkg_scheduler.NewAnalyticsJob(appServices.Settings, appServices.KV, nil, appConfig) - newScheduler.RegisterJob(analyticsJob) + newScheduler.RegisterJob(jobs.DockerClientRefresh) + newScheduler.RegisterJob(jobs.Analytics) // Send initial heartbeat on startup without blocking bootstrap. - go analyticsJob.Run(appCtx) - - eventCleanupJob := pkg_scheduler.NewEventCleanupJob(appServices.Event, appServices.Activity, appServices.Settings) - newScheduler.RegisterJob(eventCleanupJob) - - expiredSessionsCleanupJob := pkg_scheduler.NewExpiredSessionsCleanupJob(appServices.Session, appServices.Settings) - newScheduler.RegisterJob(expiredSessionsCleanupJob) - - scheduledPruneJob := pkg_scheduler.NewScheduledPruneJob(appServices.System, appServices.Settings, appServices.Notification) - newScheduler.RegisterJob(scheduledPruneJob) - - fsWatcherJob, err := pkg_scheduler.RegisterFilesystemWatcherJob(appCtx, appServices.Project, appServices.Template, appServices.Settings, appConfig.ProjectScanMaxDepth) - if err != nil { - slog.ErrorContext(appCtx, "Failed to register filesystem watcher job", "error", err) - } - - gitOpsSyncJob := pkg_scheduler.NewGitOpsSyncJob(appServices.GitOpsSync, appServices.Settings) - newScheduler.RegisterJob(gitOpsSyncJob) - - vulnerabilityScanJob := pkg_scheduler.NewVulnerabilityScanJob(appServices.Vulnerability, appServices.Settings) - newScheduler.RegisterJob(vulnerabilityScanJob) - - autoHealJob := pkg_scheduler.NewAutoHealJob(appServices.Docker, appServices.Settings, appServices.Event, appServices.Notification) - newScheduler.RegisterJob(autoHealJob) - - setupSettingsCallbacks(appCtx, appServices, appConfig, newScheduler, imagePollingJob, autoUpdateJob, environmentHealthJob, fsWatcherJob, scheduledPruneJob, vulnerabilityScanJob, autoHealJob) + go jobs.Analytics.Run(appCtx) + newScheduler.RegisterJob(jobs.EventCleanup) + newScheduler.RegisterJob(jobs.ExpiredSessionsCleanup) + newScheduler.RegisterJob(jobs.ScheduledPrune) + // FilesystemWatcher is intentionally not scheduler-registered; it watches inline + // and is only rebound on settings changes below. + newScheduler.RegisterJob(jobs.GitOpsSync) + newScheduler.RegisterJob(jobs.VulnerabilityScan) + newScheduler.RegisterJob(jobs.AutoHeal) + + setupSettingsCallbacks(appCtx, appServices, appConfig, newScheduler, jobs) } -func setupSettingsCallbacks(lifecycleCtx context.Context, appServices *Services, appConfig *config.Config, newScheduler *pkg_scheduler.JobScheduler, imagePollingJob *pkg_scheduler.ImagePollingJob, autoUpdateJob *pkg_scheduler.AutoUpdateJob, environmentHealthJob *pkg_scheduler.EnvironmentHealthJob, fsWatcherJob *pkg_scheduler.FilesystemWatcherJob, scheduledPruneJob *pkg_scheduler.ScheduledPruneJob, vulnerabilityScanJob *pkg_scheduler.VulnerabilityScanJob, autoHealJob *pkg_scheduler.AutoHealJob) { +func setupSettingsCallbacks(lifecycleCtx context.Context, appServices *di.Services, appConfig *config.Config, newScheduler *pkg_scheduler.JobScheduler, jobs *di.Jobs) { appServices.Settings.OnImagePollingSettingsChanged = func(_ context.Context) { - if err := newScheduler.RescheduleJob(lifecycleCtx, imagePollingJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.ImagePolling); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule image-polling job", "error", err) } - if err := newScheduler.RescheduleJob(lifecycleCtx, autoUpdateJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.AutoUpdate); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule auto-update job", "error", err) } if !appConfig.AgentMode { - if err := newScheduler.RescheduleJob(lifecycleCtx, environmentHealthJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.EnvironmentHealth); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule environment-health job", "error", err) } } } appServices.Settings.OnAutoUpdateSettingsChanged = func(ctx context.Context) { slog.DebugContext(lifecycleCtx, "AutoUpdateSettingsChanged callback triggered", "triggerContextCanceled", ctx.Err() != nil) - if err := newScheduler.RescheduleJob(lifecycleCtx, autoUpdateJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.AutoUpdate); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule auto-update job", "error", err) } } appServices.Settings.OnProjectsDirectoryChanged = func(_ context.Context) { - if fsWatcherJob != nil { - if err := fsWatcherJob.RestartProjectsWatcher(lifecycleCtx); err != nil { + if jobs.FilesystemWatcher != nil { + if err := jobs.FilesystemWatcher.RestartProjectsWatcher(lifecycleCtx); err != nil { slog.WarnContext(lifecycleCtx, "Failed to restart projects filesystem watcher", "error", err) } } } appServices.Settings.OnTemplatesDirectoryChanged = func(_ context.Context) { - if fsWatcherJob != nil { - if err := fsWatcherJob.RestartTemplatesWatcher(lifecycleCtx); err != nil { + if jobs.FilesystemWatcher != nil { + if err := jobs.FilesystemWatcher.RestartTemplatesWatcher(lifecycleCtx); err != nil { slog.WarnContext(lifecycleCtx, "Failed to restart templates filesystem watcher", "error", err) } } } appServices.Settings.OnScheduledPruneSettingsChanged = func(_ context.Context) { - if err := newScheduler.RescheduleJob(lifecycleCtx, scheduledPruneJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.ScheduledPrune); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule scheduled-prune job", "error", err) } } appServices.Settings.OnVulnerabilityScanSettingsChanged = func(_ context.Context) { - if err := newScheduler.RescheduleJob(lifecycleCtx, vulnerabilityScanJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.VulnerabilityScan); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule vulnerability-scan job", "error", err) } } appServices.Settings.OnAutoHealSettingsChanged = func(ctx context.Context) { - if err := newScheduler.RescheduleJob(ctx, autoHealJob); err != nil { + if err := newScheduler.RescheduleJob(ctx, jobs.AutoHeal); err != nil { slog.WarnContext(ctx, "Failed to reschedule auto-heal job", "error", err) } } @@ -116,7 +97,7 @@ func setupSettingsCallbacks(lifecycleCtx context.Context, appServices *Services, } // syncTimeoutSettingsToAgentsInternal syncs timeout settings to all connected remote environments -func syncTimeoutSettingsToAgentsInternal(ctx context.Context, appServices *Services, timeoutSettings []libarcane.SettingUpdate) { +func syncTimeoutSettingsToAgentsInternal(ctx context.Context, appServices *di.Services, timeoutSettings []libarcane.SettingUpdate) { envs, err := appServices.Environment.ListRemoteEnvironments(ctx) if err != nil { slog.WarnContext(ctx, "Failed to list remote environments for timeout sync", "error", err) diff --git a/backend/internal/bootstrap/playwright_router_bootstrap.go b/backend/internal/bootstrap/playwright_router_bootstrap.go index 94d1cf4101..a72ac53870 100644 --- a/backend/internal/bootstrap/playwright_router_bootstrap.go +++ b/backend/internal/bootstrap/playwright_router_bootstrap.go @@ -6,13 +6,14 @@ import ( "log/slog" "github.com/getarcaneapp/arcane/backend/api" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/labstack/echo/v4" ) func init() { - registerPlaywrightRoutes = []func(apiGroup *echo.Group, services *Services){ - func(apiGroup *echo.Group, svc *Services) { + registerPlaywrightRoutes = []func(apiGroup *echo.Group, services *di.Services){ + func(apiGroup *echo.Group, svc *di.Services) { playwrightService := services.NewPlaywrightService(svc.ApiKey, svc.User, svc.Federated) if playwrightService == nil { slog.Warn("Playwright service not available, skipping playwright routes") diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index ee9064a725..3f2fc05fea 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -4,8 +4,6 @@ import ( "context" "log/slog" "net" - "net/http" - "net/http/pprof" "path" "strings" @@ -18,6 +16,7 @@ import ( "github.com/getarcaneapp/arcane/backend/api/ws" "github.com/getarcaneapp/arcane/backend/frontend" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/middleware" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" @@ -25,8 +24,8 @@ import ( ) var ( - registerPlaywrightRoutes []func(apiGroup *echo.Group, services *Services) - registerBuildableRoutes []func(apiGroup *echo.Group, services *Services) + registerPlaywrightRoutes []func(apiGroup *echo.Group, services *di.Services) + registerBuildableRoutes []func(apiGroup *echo.Group, services *di.Services) ) var loggerSkipPatterns = []string{ @@ -86,7 +85,7 @@ func requestLoggerMiddlewareInternal() echo.MiddlewareFunc { } } -func createAuthValidatorInternal(appServices *Services) middleware.AuthValidator { +func createAuthValidatorInternal(appServices *di.Services) middleware.AuthValidator { return func(ctx context.Context, c echo.Context) bool { req := c.Request() // Check for API key authentication @@ -120,7 +119,7 @@ func createAuthValidatorInternal(appServices *Services) middleware.AuthValidator } } -func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) (*echo.Echo, *edge.TunnelServer) { +func setupRouter(ctx context.Context, cfg *config.Config, appServices *di.Services) (*echo.Echo, *edge.TunnelServer) { e := echo.New() e.HideBanner = true e.HidePort = true @@ -143,10 +142,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) e.Use(requestLoggerMiddlewareInternal()) //nolint:contextcheck e.Use(secureCookieContextMiddlewareInternal(trustedProxyNets)) //nolint:contextcheck - authMiddleware := middleware.NewAuthMiddleware(appServices.Auth, cfg). - WithApiKeyValidator(appServices.ApiKey). - WithEnvironmentAccessTokenResolver(appServices.Environment). - WithPermissionResolver(appServices.Role) + authMiddleware := appServices.AuthMiddleware e.Use(middleware.NewCORSMiddleware(cfg).Add()) apiGroup := e.Group("/api") @@ -189,59 +185,14 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) ) apiGroup.Use(envProxyMiddleware) - humaServices := &api.Services{ - AppContext: ctx, - User: appServices.User, - Auth: appServices.Auth, - Oidc: appServices.Oidc, - ApiKey: appServices.ApiKey, - Federated: appServices.Federated, - AppImages: appServices.AppImages, - Project: appServices.Project, - Event: appServices.Event, - Activity: appServices.Activity, - Version: appServices.Version, - Environment: appServices.Environment, - Settings: appServices.Settings, - JobSchedule: appServices.JobSchedule, - SettingsSearch: appServices.SettingsSearch, - ContainerRegistry: appServices.ContainerRegistry, - Template: appServices.Template, - Docker: appServices.Docker, - Image: appServices.Image, - ImageUpdate: appServices.ImageUpdate, - Build: appServices.Build, - BuildWorkspace: appServices.BuildWorkspace, - Volume: appServices.Volume, - Container: appServices.Container, - Network: appServices.Network, - Port: appServices.Port, - Swarm: appServices.Swarm, - Notification: appServices.Notification, - Updater: appServices.Updater, - CustomizeSearch: appServices.CustomizeSearch, - System: appServices.System, - SystemUpgrade: appServices.SystemUpgrade, - GitRepository: appServices.GitRepository, - GitOpsSync: appServices.GitOpsSync, - Webhook: appServices.Webhook, - Vulnerability: appServices.Vulnerability, - Dashboard: appServices.Dashboard, - Role: appServices.Role, - Config: cfg, - } - - _ = api.SetupAPI(e, apiGroup, cfg, humaServices) //nolint:contextcheck // app lifecycle context is carried in humaServices for detached activity work. + _ = api.SetupAPI(e, apiGroup, handlerAppCtx, cfg, appServices) //nolint:contextcheck // app lifecycle context is intentionally wrapped for detached activity work. for _, register := range registerBuildableRoutes { register(apiGroup, appServices) } - api.RegisterDiagnosticsRoutes(apiGroup, authMiddleware, ws.DefaultWebSocketMetrics()) //nolint:contextcheck - registerPprofRoutesInternal(apiGroup, authMiddleware) //nolint:contextcheck - // Remaining echo handlers (WebSocket/streaming) - ws.NewWebSocketHandler(apiGroup, appServices.Project, appServices.Container, appServices.Swarm, appServices.System, authMiddleware, cfg) //nolint:contextcheck + ws.NewWebSocketHandler(apiGroup, appServices.Project, appServices.Container, appServices.Swarm, appServices.System, appServices.Diagnostics, authMiddleware, cfg) //nolint:contextcheck // Register edge tunnel endpoint for manager to accept agent connections // This is only registered when NOT in agent mode (i.e., running as manager) @@ -327,15 +278,3 @@ func remoteAddrInTrustedProxiesInternal(remoteAddr string, nets []*net.IPNet) bo } return false } - -func registerPprofRoutesInternal(apiGroup *echo.Group, authMiddleware *middleware.AuthMiddleware) { - pprofGroup := apiGroup.Group("/debug/pprof", authMiddleware.WithAdminRequired().Add()) - pprofGroup.GET("", echo.WrapHandler(http.HandlerFunc(pprof.Index))) - pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index))) - pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) - pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) - pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) - pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) - pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace))) - pprofGroup.GET("/:name", echo.WrapHandler(http.HandlerFunc(pprof.Index))) -} diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go deleted file mode 100644 index 43b259633e..0000000000 --- a/backend/internal/bootstrap/services_bootstrap.go +++ /dev/null @@ -1,116 +0,0 @@ -package bootstrap - -import ( - "context" - "fmt" - "net/http" - - "github.com/getarcaneapp/arcane/backend/internal/config" - "github.com/getarcaneapp/arcane/backend/internal/database" - "github.com/getarcaneapp/arcane/backend/internal/services" - "github.com/getarcaneapp/arcane/backend/resources" -) - -type Services struct { - AppImages *services.ApplicationImagesService - User *services.UserService - Project *services.ProjectService - Environment *services.EnvironmentService - Settings *services.SettingsService - KV *services.KVService - JobSchedule *services.JobService - SettingsSearch *services.SettingsSearchService - CustomizeSearch *services.CustomizeSearchService - Container *services.ContainerService - Image *services.ImageService - Build *services.BuildService - BuildWorkspace *services.BuildWorkspaceService - Volume *services.VolumeService - Network *services.NetworkService - Port *services.PortService - Swarm *services.SwarmService - ImageUpdate *services.ImageUpdateService - Session *services.SessionService - Auth *services.AuthService - Oidc *services.OidcService - Docker *services.DockerClientService - Template *services.TemplateService - ContainerRegistry *services.ContainerRegistryService - System *services.SystemService - SystemUpgrade *services.SystemUpgradeService - Updater *services.UpdaterService - Event *services.EventService - Activity *services.ActivityService - Version *services.VersionService - Notification *services.NotificationService - ApiKey *services.ApiKeyService - Federated *services.FederatedCredentialService - GitRepository *services.GitRepositoryService - GitOpsSync *services.GitOpsSyncService - Webhook *services.WebhookService - Vulnerability *services.VulnerabilityService - Dashboard *services.DashboardService - Role *services.RoleService -} - -func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (svcs *Services, dockerSrvice *services.DockerClientService, err error) { - svcs = &Services{} - - svcs.Event = services.NewEventService(db, cfg, httpClient) - svcs.Activity = services.NewActivityService(db) - svcs.Settings, err = services.NewSettingsService(ctx, db) - if err != nil { - return nil, nil, fmt.Errorf("failed to settings service: %w", err) - } - svcs.KV = services.NewKVService(db) - svcs.JobSchedule = services.NewJobService(db, svcs.Settings, cfg) - svcs.SettingsSearch = services.NewSettingsSearchService() - svcs.CustomizeSearch = services.NewCustomizeSearchService() - svcs.AppImages = services.NewApplicationImagesService(resources.FS, svcs.Settings) - dockerClient := services.NewDockerClientService(ctx, db, cfg, svcs.Settings) - svcs.Docker = dockerClient - svcs.Role = services.NewRoleService(db) - svcs.User = services.NewUserService(db).WithRoleService(svcs.Role) - svcs.Session = services.NewSessionService(db) - svcs.ApiKey = services.NewApiKeyService(db, svcs.User).WithRoleService(svcs.Role) - svcs.ContainerRegistry = services.NewContainerRegistryService(db, func(ctx context.Context) (services.RegistryDaemonClient, error) { - return dockerClient.GetClient(ctx) - }, svcs.KV) - svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event, svcs.Settings, svcs.ApiKey) - svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker) - svcs.Notification = services.NewNotificationService(db, cfg, svcs.Environment) - svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification, svcs.Activity, svcs.ContainerRegistry) - svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification, svcs.Activity) - svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Vulnerability, svcs.Event) - svcs.GitRepository = services.NewGitRepositoryService(db, cfg.GitWorkDir, svcs.Event, svcs.Settings) - svcs.Build = services.NewBuildService(db, svcs.Settings, svcs.Docker, svcs.ContainerRegistry, svcs.GitRepository, svcs.Event) - svcs.BuildWorkspace = services.NewBuildWorkspaceService(svcs.Settings) - svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image, svcs.Docker, svcs.Build, cfg) - svcs.Container = services.NewContainerService(ctx, db, svcs.Event, svcs.Docker, svcs.Image, svcs.Settings, svcs.Project) - svcs.Dashboard = services.NewDashboardService( - db, - svcs.Docker, - svcs.Container, - svcs.Project, - svcs.Image, - svcs.Settings, - svcs.Vulnerability, - svcs.Environment, - svcs.Version, - ) - svcs.Volume = services.NewVolumeService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Container, svcs.Image, cfg.BackupVolumeName) - svcs.Network = services.NewNetworkService(db, svcs.Docker, svcs.Event) - svcs.Port = services.NewPortService(svcs.Docker) - svcs.Swarm = services.NewSwarmService(svcs.Docker, svcs.Settings, svcs.KV, svcs.ContainerRegistry, svcs.Environment) - svcs.Template = services.NewTemplateService(ctx, db, httpClient, svcs.Settings) - svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, svcs.Session, svcs.Role, cfg.JWTSecret, cfg) - svcs.Oidc = services.NewOidcService(svcs.Auth, svcs.Settings, cfg, httpClient) - svcs.Federated = services.NewFederatedCredentialService(db, svcs.Auth, svcs.User, svcs.Settings, svcs.Event, httpClient).WithRoleService(svcs.Role) - svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings, svcs.Activity) - svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings) - svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade, svcs.Activity) - svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Swarm, svcs.Event, svcs.Settings) - svcs.Webhook = services.NewWebhookService(db, svcs.Container, svcs.Updater, svcs.Project, svcs.GitOpsSync, svcs.Event) - - return svcs, dockerClient, nil -} diff --git a/backend/internal/di/providers.go b/backend/internal/di/providers.go new file mode 100644 index 0000000000..4d4e8b5fb4 --- /dev/null +++ b/backend/internal/di/providers.go @@ -0,0 +1,182 @@ +// Package di owns the backend's dependency-injection graph. The service +// construction order is generated by google/wire (see wire.go / wire_gen.go); +// this file holds the aggregate Services container and the small set of +// provider wrappers wire cannot synthesize on its own. +package di + +import ( + "context" + "embed" + "log/slog" + "net/http" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + pkg_scheduler "github.com/getarcaneapp/arcane/backend/pkg/scheduler" + "github.com/getarcaneapp/arcane/backend/resources" +) + +// Services is the single aggregate of every constructed service. wire populates +// every field via wire.Struct(new(Services), "*"); it is the only service +// container in the backend (handlers, jobs, and the router all read from it). +type Services struct { + AppImages *services.ApplicationImagesService + User *services.UserService + Project *services.ProjectService + Environment *services.EnvironmentService + Settings *services.SettingsService + KV *services.KVService + JobSchedule *services.JobService + SettingsSearch *services.SettingsSearchService + CustomizeSearch *services.CustomizeSearchService + Container *services.ContainerService + Image *services.ImageService + Build *services.BuildService + BuildWorkspace *services.BuildWorkspaceService + Volume *services.VolumeService + Network *services.NetworkService + Port *services.PortService + Swarm *services.SwarmService + ImageUpdate *services.ImageUpdateService + Session *services.SessionService + Auth *services.AuthService + Oidc *services.OidcService + Docker *services.DockerClientService + Template *services.TemplateService + ContainerRegistry *services.ContainerRegistryService + System *services.SystemService + SystemUpgrade *services.SystemUpgradeService + Diagnostics *services.DiagnosticsService + Updater *services.UpdaterService + Event *services.EventService + Activity *services.ActivityService + Version *services.VersionService + Notification *services.NotificationService + ApiKey *services.ApiKeyService + Federated *services.FederatedCredentialService + GitRepository *services.GitRepositoryService + GitOpsSync *services.GitOpsSyncService + Webhook *services.WebhookService + Vulnerability *services.VulnerabilityService + Dashboard *services.DashboardService + Role *services.RoleService + // AuthMiddleware is the shared Echo auth middleware. It's wired here so the + // router consumes it from the container instead of building the chain inline. + AuthMiddleware *middleware.AuthMiddleware +} + +// DockerClient returns the Docker client service. Bootstrap needs the docker +// service on its own (for Close and startup state) before the rest of the +// container is consumed; this avoids a second return value from the injector. +func (s *Services) DockerClient() *services.DockerClientService { + return s.Docker +} + +// provideResourcesFSInternal exposes the embedded resource filesystem to the graph so +// wire can call services.NewApplicationImagesService directly. +func provideResourcesFSInternal() embed.FS { + return resources.FS +} + +// The following wrappers exist only because wire cannot synthesize these +// constructors by type: they read scalar fields/package vars off the config, +// take parameters of unexported interface types, or apply a post-construction +// builder. Keeping them here (in the untagged file) lets the generated +// wire_gen.go call them in a normal build. + +// provideVersionServiceInternal supplies the bool/string scalars wire can't inject by type. +func provideVersionServiceInternal(httpClient *http.Client, cfg *config.Config, registry *services.ContainerRegistryService, docker *services.DockerClientService) *services.VersionService { + return services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, registry, docker) +} + +// provideGitRepositoryServiceInternal supplies cfg.GitWorkDir (bare string). +func provideGitRepositoryServiceInternal(db *database.DB, cfg *config.Config, event *services.EventService, settings *services.SettingsService) *services.GitRepositoryService { + return services.NewGitRepositoryService(db, cfg.GitWorkDir, event, settings) +} + +// provideVolumeServiceInternal supplies cfg.BackupVolumeName (bare string). +func provideVolumeServiceInternal(db *database.DB, docker *services.DockerClientService, event *services.EventService, settings *services.SettingsService, container *services.ContainerService, image *services.ImageService, cfg *config.Config) *services.VolumeService { + return services.NewVolumeService(db, docker, event, settings, container, image, cfg.BackupVolumeName) +} + +// provideAuthServiceInternal supplies cfg.JWTSecret (bare string). +func provideAuthServiceInternal(user *services.UserService, settings *services.SettingsService, event *services.EventService, session *services.SessionService, role *services.RoleService, cfg *config.Config) *services.AuthService { + return services.NewAuthService(user, settings, event, session, role, cfg.JWTSecret, cfg) +} + +// provideContainerRegistryServiceInternal builds the registryDaemonGetter closure from +// the docker service. registryDaemonGetter is unexported, so wire can't name it. +func provideContainerRegistryServiceInternal(db *database.DB, docker *services.DockerClientService, kv *services.KVService) *services.ContainerRegistryService { + return services.NewContainerRegistryService(db, func(ctx context.Context) (services.RegistryDaemonClient, error) { + return docker.GetClient(ctx) + }, kv) +} + +// provideUpdaterServiceInternal passes *SystemUpgradeService for the unexported +// selfUpgradeService parameter so wire never sees the unexported type. +func provideUpdaterServiceInternal(db *database.DB, settings *services.SettingsService, docker *services.DockerClientService, project *services.ProjectService, imageUpdate *services.ImageUpdateService, registry *services.ContainerRegistryService, event *services.EventService, image *services.ImageService, notification *services.NotificationService, systemUpgrade *services.SystemUpgradeService, activity *services.ActivityService) *services.UpdaterService { + return services.NewUpdaterService(db, settings, docker, project, imageUpdate, registry, event, image, notification, systemUpgrade, activity) +} + +// provideUserServiceInternal applies the RoleService builder. The builder stays on the +// type for tests; wire calls this wrapper. +func provideUserServiceInternal(db *database.DB, role *services.RoleService) *services.UserService { + return services.NewUserService(db).WithRoleService(role) +} + +// provideApiKeyServiceInternal applies the RoleService builder. +func provideApiKeyServiceInternal(db *database.DB, user *services.UserService, role *services.RoleService) *services.ApiKeyService { + return services.NewApiKeyService(db, user).WithRoleService(role) +} + +// provideFederatedCredentialServiceInternal applies the RoleService builder. +func provideFederatedCredentialServiceInternal(db *database.DB, auth *services.AuthService, user *services.UserService, settings *services.SettingsService, event *services.EventService, httpClient *http.Client, role *services.RoleService) *services.FederatedCredentialService { + return services.NewFederatedCredentialService(db, auth, user, settings, event, httpClient).WithRoleService(role) +} + +// provideAuthMiddlewareInternal builds the shared Echo auth middleware. The builder +// methods take interfaces (ApiKeyValidator / EnvironmentAccessTokenResolver / +// PermissionResolver) that the concrete services already satisfy. +func provideAuthMiddlewareInternal(auth *services.AuthService, apiKey *services.ApiKeyService, env *services.EnvironmentService, role *services.RoleService, cfg *config.Config) *middleware.AuthMiddleware { + return middleware.NewAuthMiddleware(auth, cfg). + WithApiKeyValidator(apiKey). + WithEnvironmentAccessTokenResolver(env). + WithPermissionResolver(role) +} + +// Jobs aggregates every scheduler job. wire populates it via wire.Struct; the +// bootstrap then registers the constructed jobs with the scheduler and wires the +// settings-change callbacks — those are post-construction concerns wire does not handle. +type Jobs struct { + AutoUpdate *pkg_scheduler.AutoUpdateJob + ImagePolling *pkg_scheduler.ImagePollingJob + EnvironmentHealth *pkg_scheduler.EnvironmentHealthJob + DockerClientRefresh *pkg_scheduler.DockerClientRefreshJob + Analytics *pkg_scheduler.AnalyticsJob + EventCleanup *pkg_scheduler.EventCleanupJob + ExpiredSessionsCleanup *pkg_scheduler.ExpiredSessionsCleanupJob + ScheduledPrune *pkg_scheduler.ScheduledPruneJob + FilesystemWatcher *pkg_scheduler.FilesystemWatcherJob + GitOpsSync *pkg_scheduler.GitOpsSyncJob + VulnerabilityScan *pkg_scheduler.VulnerabilityScanJob + AutoHeal *pkg_scheduler.AutoHealJob +} + +// provideAnalyticsJobInternal preserves the existing nil http.Client (the analytics job +// builds its own default client when none is supplied). +func provideAnalyticsJobInternal(settings *services.SettingsService, kv *services.KVService, cfg *config.Config) *pkg_scheduler.AnalyticsJob { + return pkg_scheduler.NewAnalyticsJob(settings, kv, nil, cfg) +} + +// provideFilesystemWatcherJobInternal supplies ctx + cfg.ProjectScanMaxDepth and keeps the +// watcher-setup failure non-fatal (logged; a nil job is tolerated downstream), matching +// the previous bootstrap behavior. +func provideFilesystemWatcherJobInternal(ctx context.Context, project *services.ProjectService, template *services.TemplateService, settings *services.SettingsService, cfg *config.Config) *pkg_scheduler.FilesystemWatcherJob { + job, err := pkg_scheduler.RegisterFilesystemWatcherJob(ctx, project, template, settings, cfg.ProjectScanMaxDepth) + if err != nil { + slog.ErrorContext(ctx, "Failed to register filesystem watcher job", "error", err) + } + return job +} diff --git a/backend/internal/di/wire.go b/backend/internal/di/wire.go new file mode 100644 index 0000000000..6d03446cda --- /dev/null +++ b/backend/internal/di/wire.go @@ -0,0 +1,115 @@ +//go:build wireinject + +// This file is consumed only by wire's code generator (build tag wireinject) +// and is excluded from normal builds. Run `go generate ./internal/di/` after +// changing the provider set to regenerate wire_gen.go. + +package di + +import ( + "context" + "net/http" + + "github.com/google/wire" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/services" + pkg_scheduler "github.com/getarcaneapp/arcane/backend/pkg/scheduler" +) + +// ServiceSet is the single, central provider set for the whole backend. Every +// service constructor (or the wrapper it requires) is listed here exactly once; +// wire derives the construction order from the dependency graph, so ordering is +// no longer maintained by hand. wire.Struct assembles the aggregate Services. +var ServiceSet = wire.NewSet( + // Infra providers that are not themselves services. + provideResourcesFSInternal, + + // Services wire constructs directly via their real constructors. + services.NewEventService, + services.NewActivityService, + services.NewSettingsService, // returns (*SettingsService, error); wire threads the error. + services.NewKVService, + services.NewJobService, + services.NewSettingsSearchService, + services.NewCustomizeSearchService, + services.NewApplicationImagesService, + services.NewDockerClientService, + services.NewRoleService, + services.NewSessionService, + services.NewEnvironmentService, + services.NewNotificationService, + services.NewVulnerabilityService, + services.NewImageUpdateService, + services.NewImageService, + services.NewBuildService, + services.NewBuildWorkspaceService, + services.NewProjectService, + services.NewContainerService, + services.NewDashboardService, + services.NewNetworkService, + services.NewPortService, + services.NewSwarmService, + services.NewTemplateService, + services.NewOidcService, + services.NewSystemService, + services.NewSystemUpgradeService, + services.NewDiagnosticsService, + services.NewGitOpsSyncService, + services.NewWebhookService, + + // Services that require a wrapper (scalar config field, unexported parameter, + // or post-construction RoleService builder). See providers.go. + provideVersionServiceInternal, + provideGitRepositoryServiceInternal, + provideVolumeServiceInternal, + provideAuthServiceInternal, + provideContainerRegistryServiceInternal, + provideUpdaterServiceInternal, + provideUserServiceInternal, + provideApiKeyServiceInternal, + provideFederatedCredentialServiceInternal, + + // Shared Echo auth middleware (built from the auth-related services + config). + provideAuthMiddlewareInternal, + + // Assemble the aggregate container from everything above. + wire.Struct(new(Services), "*"), +) + +// InitializeServices builds the full service graph. ctx, db, cfg, and httpClient +// are graph inputs supplied by the caller; every other value is constructed by +// ServiceSet. The generated implementation lives in wire_gen.go. +func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (*Services, error) { + panic(wire.Build(ServiceSet)) +} + +// JobSet builds every scheduler job from an already-constructed *Services (whose +// fields are exposed to the graph via wire.FieldsOf) plus the app context and config. +var JobSet = wire.NewSet( + wire.FieldsOf(new(*Services), + "Updater", "Settings", "ImageUpdate", "Environment", "Docker", "KV", + "Event", "Activity", "Session", "System", "Notification", "Project", + "Template", "GitOpsSync", "Vulnerability", + ), + pkg_scheduler.NewAutoUpdateJob, + pkg_scheduler.NewImagePollingJob, + pkg_scheduler.NewEnvironmentHealthJob, + pkg_scheduler.NewDockerClientRefreshJob, + provideAnalyticsJobInternal, + pkg_scheduler.NewEventCleanupJob, + pkg_scheduler.NewExpiredSessionsCleanupJob, + pkg_scheduler.NewScheduledPruneJob, + provideFilesystemWatcherJobInternal, + pkg_scheduler.NewGitOpsSyncJob, + pkg_scheduler.NewVulnerabilityScanJob, + pkg_scheduler.NewAutoHealJob, + wire.Struct(new(Jobs), "*"), +) + +// InitializeJobs constructs all scheduler jobs from the built services. The caller +// registers them with the scheduler and wires the settings-change callbacks. +func InitializeJobs(ctx context.Context, cfg *config.Config, svcs *Services) *Jobs { + panic(wire.Build(JobSet)) +} diff --git a/backend/internal/di/wire_gen.go b/backend/internal/di/wire_gen.go new file mode 100644 index 0000000000..3e07a1753e --- /dev/null +++ b/backend/internal/di/wire_gen.go @@ -0,0 +1,191 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package di + +import ( + "context" + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/scheduler" + "github.com/google/wire" + "net/http" +) + +// Injectors from wire.go: + +// InitializeServices builds the full service graph. ctx, db, cfg, and httpClient +// are graph inputs supplied by the caller; every other value is constructed by +// ServiceSet. The generated implementation lives in wire_gen.go. +func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (*Services, error) { + fs := provideResourcesFSInternal() + settingsService, err := services.NewSettingsService(ctx, db) + if err != nil { + return nil, err + } + applicationImagesService := services.NewApplicationImagesService(fs, settingsService) + roleService := services.NewRoleService(db) + userService := provideUserServiceInternal(db, roleService) + eventService := services.NewEventService(db, cfg, httpClient) + dockerClientService := services.NewDockerClientService(ctx, db, cfg, settingsService) + kvService := services.NewKVService(db) + containerRegistryService := provideContainerRegistryServiceInternal(db, dockerClientService, kvService) + apiKeyService := provideApiKeyServiceInternal(db, userService, roleService) + environmentService := services.NewEnvironmentService(db, httpClient, dockerClientService, eventService, settingsService, apiKeyService) + notificationService := services.NewNotificationService(db, cfg, environmentService) + activityService := services.NewActivityService(db) + imageUpdateService := services.NewImageUpdateService(db, settingsService, containerRegistryService, dockerClientService, eventService, notificationService, activityService) + vulnerabilityService := services.NewVulnerabilityService(db, dockerClientService, eventService, settingsService, notificationService, activityService, containerRegistryService) + imageService := services.NewImageService(db, dockerClientService, containerRegistryService, imageUpdateService, vulnerabilityService, eventService) + gitRepositoryService := provideGitRepositoryServiceInternal(db, cfg, eventService, settingsService) + buildService := services.NewBuildService(db, settingsService, dockerClientService, containerRegistryService, gitRepositoryService, eventService) + projectService := services.NewProjectService(db, settingsService, eventService, imageService, dockerClientService, buildService, cfg) + jobService := services.NewJobService(db, settingsService, cfg) + settingsSearchService := services.NewSettingsSearchService() + customizeSearchService := services.NewCustomizeSearchService() + containerService := services.NewContainerService(ctx, db, eventService, dockerClientService, imageService, settingsService, projectService) + buildWorkspaceService := services.NewBuildWorkspaceService(settingsService) + volumeService := provideVolumeServiceInternal(db, dockerClientService, eventService, settingsService, containerService, imageService, cfg) + networkService := services.NewNetworkService(db, dockerClientService, eventService) + portService := services.NewPortService(dockerClientService) + swarmService := services.NewSwarmService(dockerClientService, settingsService, kvService, containerRegistryService, environmentService) + sessionService := services.NewSessionService(db) + authService := provideAuthServiceInternal(userService, settingsService, eventService, sessionService, roleService, cfg) + oidcService := services.NewOidcService(authService, settingsService, cfg, httpClient) + templateService := services.NewTemplateService(ctx, db, httpClient, settingsService) + systemService := services.NewSystemService(db, dockerClientService, containerService, imageService, volumeService, networkService, settingsService, activityService) + versionService := provideVersionServiceInternal(httpClient, cfg, containerRegistryService, dockerClientService) + systemUpgradeService := services.NewSystemUpgradeService(dockerClientService, versionService, eventService, settingsService) + diagnosticsService := services.NewDiagnosticsService() + updaterService := provideUpdaterServiceInternal(db, settingsService, dockerClientService, projectService, imageUpdateService, containerRegistryService, eventService, imageService, notificationService, systemUpgradeService, activityService) + federatedCredentialService := provideFederatedCredentialServiceInternal(db, authService, userService, settingsService, eventService, httpClient, roleService) + gitOpsSyncService := services.NewGitOpsSyncService(db, gitRepositoryService, projectService, swarmService, eventService, settingsService) + webhookService := services.NewWebhookService(db, containerService, updaterService, projectService, gitOpsSyncService, eventService) + dashboardService := services.NewDashboardService(db, dockerClientService, containerService, projectService, imageService, settingsService, vulnerabilityService, environmentService, versionService) + authMiddleware := provideAuthMiddlewareInternal(authService, apiKeyService, environmentService, roleService, cfg) + diServices := &Services{ + AppImages: applicationImagesService, + User: userService, + Project: projectService, + Environment: environmentService, + Settings: settingsService, + KV: kvService, + JobSchedule: jobService, + SettingsSearch: settingsSearchService, + CustomizeSearch: customizeSearchService, + Container: containerService, + Image: imageService, + Build: buildService, + BuildWorkspace: buildWorkspaceService, + Volume: volumeService, + Network: networkService, + Port: portService, + Swarm: swarmService, + ImageUpdate: imageUpdateService, + Session: sessionService, + Auth: authService, + Oidc: oidcService, + Docker: dockerClientService, + Template: templateService, + ContainerRegistry: containerRegistryService, + System: systemService, + SystemUpgrade: systemUpgradeService, + Diagnostics: diagnosticsService, + Updater: updaterService, + Event: eventService, + Activity: activityService, + Version: versionService, + Notification: notificationService, + ApiKey: apiKeyService, + Federated: federatedCredentialService, + GitRepository: gitRepositoryService, + GitOpsSync: gitOpsSyncService, + Webhook: webhookService, + Vulnerability: vulnerabilityService, + Dashboard: dashboardService, + Role: roleService, + AuthMiddleware: authMiddleware, + } + return diServices, nil +} + +// InitializeJobs constructs all scheduler jobs from the built services. The caller +// registers them with the scheduler and wires the settings-change callbacks. +func InitializeJobs(ctx context.Context, cfg *config.Config, svcs *Services) *Jobs { + updaterService := svcs.Updater + settingsService := svcs.Settings + autoUpdateJob := scheduler.NewAutoUpdateJob(updaterService, settingsService) + imageUpdateService := svcs.ImageUpdate + environmentService := svcs.Environment + imagePollingJob := scheduler.NewImagePollingJob(imageUpdateService, settingsService, environmentService) + environmentHealthJob := scheduler.NewEnvironmentHealthJob(environmentService, settingsService) + dockerClientService := svcs.Docker + dockerClientRefreshJob := scheduler.NewDockerClientRefreshJob(dockerClientService, settingsService) + kvService := svcs.KV + analyticsJob := provideAnalyticsJobInternal(settingsService, kvService, cfg) + eventService := svcs.Event + activityService := svcs.Activity + eventCleanupJob := scheduler.NewEventCleanupJob(eventService, activityService, settingsService) + sessionService := svcs.Session + expiredSessionsCleanupJob := scheduler.NewExpiredSessionsCleanupJob(sessionService, settingsService) + systemService := svcs.System + notificationService := svcs.Notification + scheduledPruneJob := scheduler.NewScheduledPruneJob(systemService, settingsService, notificationService) + projectService := svcs.Project + templateService := svcs.Template + filesystemWatcherJob := provideFilesystemWatcherJobInternal(ctx, projectService, templateService, settingsService, cfg) + gitOpsSyncService := svcs.GitOpsSync + gitOpsSyncJob := scheduler.NewGitOpsSyncJob(gitOpsSyncService, settingsService) + vulnerabilityService := svcs.Vulnerability + vulnerabilityScanJob := scheduler.NewVulnerabilityScanJob(vulnerabilityService, settingsService) + autoHealJob := scheduler.NewAutoHealJob(dockerClientService, settingsService, eventService, notificationService) + jobs := &Jobs{ + AutoUpdate: autoUpdateJob, + ImagePolling: imagePollingJob, + EnvironmentHealth: environmentHealthJob, + DockerClientRefresh: dockerClientRefreshJob, + Analytics: analyticsJob, + EventCleanup: eventCleanupJob, + ExpiredSessionsCleanup: expiredSessionsCleanupJob, + ScheduledPrune: scheduledPruneJob, + FilesystemWatcher: filesystemWatcherJob, + GitOpsSync: gitOpsSyncJob, + VulnerabilityScan: vulnerabilityScanJob, + AutoHeal: autoHealJob, + } + return jobs +} + +// wire.go: + +// ServiceSet is the single, central provider set for the whole backend. Every +// service constructor (or the wrapper it requires) is listed here exactly once; +// wire derives the construction order from the dependency graph, so ordering is +// no longer maintained by hand. wire.Struct assembles the aggregate Services. +var ServiceSet = wire.NewSet( + + provideResourcesFSInternal, services.NewEventService, services.NewActivityService, services.NewSettingsService, services.NewKVService, services.NewJobService, services.NewSettingsSearchService, services.NewCustomizeSearchService, services.NewApplicationImagesService, services.NewDockerClientService, services.NewRoleService, services.NewSessionService, services.NewEnvironmentService, services.NewNotificationService, services.NewVulnerabilityService, services.NewImageUpdateService, services.NewImageService, services.NewBuildService, services.NewBuildWorkspaceService, services.NewProjectService, services.NewContainerService, services.NewDashboardService, services.NewNetworkService, services.NewPortService, services.NewSwarmService, services.NewTemplateService, services.NewOidcService, services.NewSystemService, services.NewSystemUpgradeService, services.NewDiagnosticsService, services.NewGitOpsSyncService, services.NewWebhookService, provideVersionServiceInternal, + provideGitRepositoryServiceInternal, + provideVolumeServiceInternal, + provideAuthServiceInternal, + provideContainerRegistryServiceInternal, + provideUpdaterServiceInternal, + provideUserServiceInternal, + provideApiKeyServiceInternal, + provideFederatedCredentialServiceInternal, + + provideAuthMiddlewareInternal, wire.Struct(new(Services), "*"), +) + +// JobSet builds every scheduler job from an already-constructed *Services (whose +// fields are exposed to the graph via wire.FieldsOf) plus the app context and config. +var JobSet = wire.NewSet(wire.FieldsOf(new(*Services), + "Updater", "Settings", "ImageUpdate", "Environment", "Docker", "KV", + "Event", "Activity", "Session", "System", "Notification", "Project", + "Template", "GitOpsSync", "Vulnerability", +), scheduler.NewAutoUpdateJob, scheduler.NewImagePollingJob, scheduler.NewEnvironmentHealthJob, scheduler.NewDockerClientRefreshJob, provideAnalyticsJobInternal, scheduler.NewEventCleanupJob, scheduler.NewExpiredSessionsCleanupJob, scheduler.NewScheduledPruneJob, provideFilesystemWatcherJobInternal, scheduler.NewGitOpsSyncJob, scheduler.NewVulnerabilityScanJob, scheduler.NewAutoHealJob, wire.Struct(new(Jobs), "*"), +) diff --git a/backend/internal/services/diagnostics_service.go b/backend/internal/services/diagnostics_service.go new file mode 100644 index 0000000000..63a784f084 --- /dev/null +++ b/backend/internal/services/diagnostics_service.go @@ -0,0 +1,85 @@ +package services + +import ( + "runtime" + "runtime/debug" + "time" + + "github.com/getarcaneapp/arcane/types/system" +) + +// recentGCPauseSamples is the number of recent GC pause durations reported. +const recentGCPauseSamples = 16 + +// DiagnosticsService gathers Go runtime, memory, and garbage-collector +// statistics for the diagnostics endpoints. It holds no external dependencies; +// WebSocket metrics and worker-goroutine counts are merged in at the handler +// layer to avoid an import cycle with the api/ws package. +type DiagnosticsService struct { + startedAt time.Time +} + +// NewDiagnosticsService returns a DiagnosticsService. startedAt is captured at +// construction (≈ process start) and used to report uptime. +func NewDiagnosticsService() *DiagnosticsService { + return &DiagnosticsService{startedAt: time.Now()} +} + +// Collect samples the current runtime, memory, and GC state. +func (s *DiagnosticsService) Collect() (system.RuntimeInfo, system.MemoryInfo, system.GCInfo) { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + var gc debug.GCStats + debug.ReadGCStats(&gc) + + rt := system.RuntimeInfo{ + Goroutines: runtime.NumGoroutine(), + GOMAXPROCS: runtime.GOMAXPROCS(0), + NumCPU: runtime.NumCPU(), + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + NumCgoCall: runtime.NumCgoCall(), + UptimeSeconds: int64(time.Since(s.startedAt).Seconds()), + } + + mi := system.MemoryInfo{ + Alloc: mem.Alloc, + TotalAlloc: mem.TotalAlloc, + Sys: mem.Sys, + HeapAlloc: mem.HeapAlloc, + HeapSys: mem.HeapSys, + HeapInuse: mem.HeapInuse, + HeapIdle: mem.HeapIdle, + HeapReleased: mem.HeapReleased, + HeapObjects: mem.HeapObjects, + StackInuse: mem.StackInuse, + StackSys: mem.StackSys, + MSpanInuse: mem.MSpanInuse, + MCacheInuse: mem.MCacheInuse, + NextGC: mem.NextGC, + NumGC: mem.NumGC, + NumForcedGC: mem.NumForcedGC, + GCCPUFraction: mem.GCCPUFraction, + } + + // gc.Pause is ordered most-recent-first; cap the slice we expose. + pauses := gc.Pause + if len(pauses) > recentGCPauseSamples { + pauses = pauses[:recentGCPauseSamples] + } + recent := make([]int64, len(pauses)) + for i, p := range pauses { + recent[i] = p.Nanoseconds() + } + + gi := system.GCInfo{ + LastGC: gc.LastGC, + NumGC: gc.NumGC, + PauseTotalNs: gc.PauseTotal.Nanoseconds(), + RecentPausesNs: recent, + } + + return rt, mi, gi +} diff --git a/backend/internal/services/diagnostics_service_test.go b/backend/internal/services/diagnostics_service_test.go new file mode 100644 index 0000000000..3708702091 --- /dev/null +++ b/backend/internal/services/diagnostics_service_test.go @@ -0,0 +1,22 @@ +package services + +import "testing" + +func TestDiagnosticsServiceCollect(t *testing.T) { + s := NewDiagnosticsService() + + rt, mem, _ := s.Collect() + + if rt.Goroutines <= 0 { + t.Errorf("expected a positive goroutine count, got %d", rt.Goroutines) + } + if rt.GoVersion == "" { + t.Error("expected a non-empty Go version") + } + if rt.NumCPU <= 0 { + t.Errorf("expected a positive CPU count, got %d", rt.NumCPU) + } + if mem.Sys == 0 { + t.Error("expected non-zero Sys memory") + } +} diff --git a/backend/pkg/authz/catalog.go b/backend/pkg/authz/catalog.go index fd75a75f57..4cc340153e 100644 --- a/backend/pkg/authz/catalog.go +++ b/backend/pkg/authz/catalog.go @@ -92,6 +92,9 @@ var permissionCatalog = []PermissionCatalogResource{ {"customize", "Customize", PermissionScopeGlobal, []PermissionCatalogAction{ {"manage", PermCustomizeManage, "Manage", ""}, }}, + {"diagnostics", "Diagnostics", PermissionScopeGlobal, []PermissionCatalogAction{ + {"read", PermDiagnosticsRead, "View", "View runtime diagnostics, pprof profiles, and backend logs"}, + }}, {"containers", "Containers", PermissionScopeEnv, []PermissionCatalogAction{ {"list", PermContainersList, "List", ""}, {"read", PermContainersRead, "Read", ""}, diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go index cd45b8c55f..0e5c3b00a9 100644 --- a/backend/pkg/authz/permissions.go +++ b/backend/pkg/authz/permissions.go @@ -79,6 +79,11 @@ const ( PermEventsRead = "events:read" PermCustomizeManage = "customize:manage" + + // PermDiagnosticsRead gates the admin-only runtime diagnostics surface + // (runtime/memory/GC stats, WebSocket metrics, pprof profiles, and the live + // backend log tail). Global-scoped; seeded only into the Admin role. + PermDiagnosticsRead = "diagnostics:read" ) // Env-scoped permissions (resolved against the {id} env ID in the path). diff --git a/backend/pkg/libarcane/logstream/logstream.go b/backend/pkg/libarcane/logstream/logstream.go new file mode 100644 index 0000000000..1487ec6313 --- /dev/null +++ b/backend/pkg/libarcane/logstream/logstream.go @@ -0,0 +1,152 @@ +// Package logstream captures the application's own slog output into a bounded +// in-memory ring buffer and fans it out to live subscribers. It backs the +// diagnostics endpoints' "recent logs" backlog and the live backend-log +// WebSocket stream. It deliberately depends only on the shared types package so +// both bootstrap (which installs the slog handler) and the API handlers (which +// read it) can use it without an import cycle. +package logstream + +import ( + "context" + "log/slog" + "sync" + + "github.com/getarcaneapp/arcane/types/system" +) + +// defaultRingCapacity is the number of recent log entries retained in memory. +const defaultRingCapacity = 1000 + +// subscriberBuffer is the per-subscriber channel buffer. Slow consumers have +// entries dropped rather than blocking the logger. +const subscriberBuffer = 256 + +// Entry is a single captured log record. +type Entry = system.LogEntry + +// Broadcaster keeps a bounded ring buffer of recent log entries and fans new +// entries out to any active subscribers. It is safe for concurrent use. +type Broadcaster struct { + mu sync.Mutex + buf []Entry + start int + size int + capN int + subs map[chan Entry]struct{} +} + +// New returns a Broadcaster retaining up to capacity recent entries. +func New(capacity int) *Broadcaster { + if capacity <= 0 { + capacity = defaultRingCapacity + } + return &Broadcaster{ + buf: make([]Entry, capacity), + capN: capacity, + subs: make(map[chan Entry]struct{}), + } +} + +// Append records an entry in the ring buffer and delivers it to subscribers. +// Delivery is non-blocking; a subscriber whose buffer is full drops the entry. +func (b *Broadcaster) Append(e Entry) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.size < b.capN { + b.buf[(b.start+b.size)%b.capN] = e + b.size++ + } else { + b.buf[b.start] = e + b.start = (b.start + 1) % b.capN + } + + // Non-blocking sends under the lock: cancel() also takes the lock before + // closing a subscriber channel, so we never send on a closed channel. + for ch := range b.subs { + select { + case ch <- e: + default: + } + } +} + +// Recent returns the buffered entries in chronological order (oldest first). +func (b *Broadcaster) Recent() []Entry { + b.mu.Lock() + defer b.mu.Unlock() + + out := make([]Entry, b.size) + for i := range b.size { + out[i] = b.buf[(b.start+i)%b.capN] + } + return out +} + +// Subscribe registers a new live subscriber. It returns a receive-only channel +// of subsequent entries and a cancel func that unsubscribes and closes the +// channel. cancel is idempotent. +func (b *Broadcaster) Subscribe() (<-chan Entry, func()) { + ch := make(chan Entry, subscriberBuffer) + b.mu.Lock() + b.subs[ch] = struct{}{} + b.mu.Unlock() + + var once sync.Once + cancel := func() { + once.Do(func() { + b.mu.Lock() + delete(b.subs, ch) + close(ch) + b.mu.Unlock() + }) + } + return ch, cancel +} + +var defaultBroadcaster = New(defaultRingCapacity) + +// Default returns the package-level Broadcaster singleton. +func Default() *Broadcaster { return defaultBroadcaster } + +// slogHandler wraps a base slog.Handler, forwarding every record to it while +// also appending it to a Broadcaster. +type slogHandler struct { + base slog.Handler + b *Broadcaster +} + +// NewSlogHandler returns a slog.Handler that tees records to base and to b. +func NewSlogHandler(base slog.Handler, b *Broadcaster) slog.Handler { + return &slogHandler{base: base, b: b} +} + +func (h *slogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.base.Enabled(ctx, level) +} + +func (h *slogHandler) Handle(ctx context.Context, r slog.Record) error { + var attrs map[string]any + if r.NumAttrs() > 0 { + attrs = make(map[string]any, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + } + h.b.Append(Entry{ + Time: r.Time, + Level: r.Level.String(), + Message: r.Message, + Attrs: attrs, + }) + return h.base.Handle(ctx, r) +} + +func (h *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &slogHandler{base: h.base.WithAttrs(attrs), b: h.b} +} + +func (h *slogHandler) WithGroup(name string) slog.Handler { + return &slogHandler{base: h.base.WithGroup(name), b: h.b} +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 958d44b932..2b11fe2813 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -3024,6 +3024,97 @@ "rbac_migration_banner_title": "Welcome to RBAC", "rbac_migration_banner_body": "Your access was migrated to Viewer on all environments. Admins can promote users from Settings → Users.", "rbac_migration_banner_dismiss": "Dismiss", + "_comment_diagnostics": "=== DIAGNOSTICS ===", + "diagnostics_title": "Diagnostics", + "diagnostics_description": "Live Go runtime, profiling, and backend logs.", + "diagnostics_status_live": "Live", + "diagnostics_status_paused": "Paused", + "diagnostics_status_connecting": "Connecting…", + "diagnostics_updated_ago": "updated {ago}", + "diagnostics_just_now": "just now", + "diagnostics_seconds_ago": "{seconds}s ago", + "diagnostics_pause": "Pause", + "diagnostics_resume": "Resume", + "diagnostics_refresh": "Refresh", + "diagnostics_loading": "Loading diagnostics…", + "diagnostics_error_load": "Failed to load diagnostics", + "diagnostics_error_download": "Download failed", + "diagnostics_error_dump": "Failed to load dump", + "diagnostics_stat_goroutines": "Goroutines", + "diagnostics_stat_heap_alloc": "Heap Alloc", + "diagnostics_stat_ws_conns": "WS Conns", + "diagnostics_stat_gc_cycles": "GC Cycles", + "diagnostics_stat_cpu_procs": "CPU / Procs", + "diagnostics_stat_uptime": "Uptime", + "diagnostics_section_runtime": "Runtime", + "diagnostics_runtime_go_version": "Go version", + "diagnostics_runtime_platform": "Platform", + "diagnostics_runtime_gomaxprocs": "GOMAXPROCS", + "diagnostics_runtime_num_cpu": "Logical CPUs", + "diagnostics_runtime_goroutines": "Goroutines", + "diagnostics_runtime_ws_workers": "WS worker goroutines", + "diagnostics_runtime_cgo_calls": "Cgo calls", + "diagnostics_runtime_uptime": "Uptime", + "diagnostics_section_memory": "Memory / Heap", + "diagnostics_mem_in_use": "In use", + "diagnostics_mem_idle": "Idle", + "diagnostics_mem_released": "Released", + "diagnostics_mem_heap_alloc": "Heap alloc", + "diagnostics_mem_heap_sys": "Heap sys", + "diagnostics_mem_heap_objects": "Heap objects", + "diagnostics_mem_stack_in_use": "Stack in use", + "diagnostics_mem_total_alloc": "Total alloc (cumulative)", + "diagnostics_mem_sys_total": "Sys (total)", + "diagnostics_mem_next_gc": "Next GC target", + "diagnostics_mem_gc_cpu_fraction": "GC CPU fraction", + "diagnostics_section_gc": "Garbage Collector", + "diagnostics_gc_total_cycles": "Total cycles", + "diagnostics_gc_forced_cycles": "Forced cycles", + "diagnostics_gc_total_pause": "Total pause", + "diagnostics_gc_last": "Last GC", + "diagnostics_gc_recent_pauses": "Recent pauses (newest left)", + "diagnostics_section_websocket": "WebSocket Connections", + "diagnostics_ws_project_logs": "Project logs", + "diagnostics_ws_container_logs": "Container logs", + "diagnostics_ws_container_stats": "Container stats", + "diagnostics_ws_terminals": "Terminals", + "diagnostics_ws_system_stats": "System stats", + "diagnostics_ws_service_logs": "Service logs", + "diagnostics_ws_kind_terminal": "Terminal", + "diagnostics_section_connections": "Active Connections ({count})", + "diagnostics_conn_kind": "Kind", + "diagnostics_conn_resource": "Resource", + "diagnostics_conn_client_ip": "Client IP", + "diagnostics_conn_user": "User", + "diagnostics_conn_since": "Since", + "diagnostics_section_logs": "Live Backend Logs", + "diagnostics_logs_streaming": "Streaming", + "diagnostics_logs_disconnected": "Disconnected", + "diagnostics_logs_count": "{count} lines", + "diagnostics_logs_all_levels": "All levels", + "diagnostics_logs_level_error": "Error", + "diagnostics_logs_level_warn": "Warn", + "diagnostics_logs_level_info": "Info", + "diagnostics_logs_level_debug": "Debug", + "diagnostics_logs_filter_placeholder": "Filter…", + "diagnostics_logs_auto_scroll": "Auto-scroll", + "diagnostics_logs_clear": "Clear", + "diagnostics_logs_empty": "No log entries.", + "diagnostics_section_dumps": "Inline Dumps", + "diagnostics_dump_goroutine": "Goroutine stacks", + "diagnostics_dump_heap": "Heap profile", + "diagnostics_dump_loading": "Loading…", + "diagnostics_dump_empty": "No data.", + "diagnostics_section_profiles": "Download pprof Profiles", + "diagnostics_profiles_hint": "Open the downloaded files with go tool pprof / go tool trace.", + "diagnostics_profile_heap": "Heap", + "diagnostics_profile_goroutine": "Goroutine", + "diagnostics_profile_allocs": "Allocs", + "diagnostics_profile_block": "Block", + "diagnostics_profile_mutex": "Mutex", + "diagnostics_profile_threadcreate": "Threads", + "diagnostics_profile_cpu": "CPU (30s)", + "diagnostics_profile_trace": "Trace (5s)", "_comment_no_access": "=== NO ACCESS PAGE ===", "no_access_page_title": "You don't have access to anything yet", "no_access_page_body": "Your Arcane administrator hasn't assigned you any role permissions. Ask them to add a role to your account from Settings → Users." diff --git a/frontend/project.inlang/cache/plugins/2sy648wh9sugi b/frontend/project.inlang/cache/plugins/2sy648wh9sugi index 1f9c4e487b..3c313baea2 100644 --- a/frontend/project.inlang/cache/plugins/2sy648wh9sugi +++ b/frontend/project.inlang/cache/plugins/2sy648wh9sugi @@ -2008,28 +2008,34 @@ function parsePattern2(value) { continue; } if (char === "{") { - let variableName = ""; - let closingIndex = -1; - for (let cursor = index + 1; cursor < value.length; cursor += 1) { - const current = value[cursor]; - if (current === "}") { - closingIndex = cursor; - break; - } - variableName += current; - } + const closingIndex = findPlaceholderClosingIndex(value, index); if (closingIndex === -1) { buffer += char; continue; } + const placeholder = value.slice(index + 1, closingIndex); + const markupNode = parseMarkupPlaceholder(placeholder); flushBuffer(); + if (markupNode) { + for (const option of markupNode.options ?? []) { + if (option.value.type === "variable-reference") { + declarations.push({ + type: "input-variable", + name: option.value.name + }); + } + } + pattern.push(markupNode); + index = closingIndex; + continue; + } declarations.push({ type: "input-variable", - name: variableName + name: placeholder }); pattern.push({ type: "expression", - arg: { type: "variable-reference", name: variableName } + arg: { type: "variable-reference", name: placeholder } }); index = closingIndex; continue; @@ -2042,6 +2048,176 @@ function parsePattern2(value) { pattern }; } +function findPlaceholderClosingIndex(value, openingIndex) { + let inQuotedLiteral = false; + for (let cursor = openingIndex + 1; cursor < value.length; cursor += 1) { + const current = value[cursor]; + if (inQuotedLiteral && current === "\\") { + cursor += 1; + continue; + } + if (current === "|") { + inQuotedLiteral = !inQuotedLiteral; + continue; + } + if (current === "}" && inQuotedLiteral === false) { + return cursor; + } + } + return -1; +} +function parseMarkupPlaceholder(placeholder) { + if (placeholder.startsWith("#")) { + const parsed = parseMarkupBody(placeholder.slice(1), true); + if (!parsed) { + throw new Error(`Invalid markup placeholder: {${placeholder}}`); + } + const { name, options, attributes, standalone } = parsed; + return { + type: standalone ? "markup-standalone" : "markup-start", + name, + ...options.length > 0 ? { options } : {}, + ...attributes.length > 0 ? { attributes } : {} + }; + } + if (placeholder.startsWith("/")) { + const parsed = parseMarkupBody(placeholder.slice(1), false); + if (!parsed) { + throw new Error(`Invalid markup placeholder: {${placeholder}}`); + } + const { name, options, attributes } = parsed; + return { + type: "markup-end", + name, + ...options.length > 0 ? { options } : {}, + ...attributes.length > 0 ? { attributes } : {} + }; + } + return void 0; +} +function parseMarkupBody(body, allowStandalone) { + let index = 0; + const options = []; + const attributes = []; + let standalone = false; + index = skipWhitespace(body, index); + const nameToken = readNameToken(body, index); + if (!nameToken) return void 0; + const name = nameToken.value; + index = nameToken.nextIndex; + while (index < body.length) { + index = skipWhitespace(body, index); + if (index >= body.length) break; + if (allowStandalone && body[index] === "/") { + const trailing = body.slice(index + 1).trim(); + if (trailing.length > 0) return void 0; + standalone = true; + index = body.length; + break; + } + if (body[index] === "@") { + index += 1; + const attributeName = readIdentifier(body, index); + if (!attributeName) return void 0; + index = attributeName.nextIndex; + index = skipWhitespace(body, index); + if (body[index] === "=") { + index += 1; + index = skipWhitespace(body, index); + const attributeValue = parseMarkupValue(body, index); + if (!attributeValue) return void 0; + if (attributeValue.value.type === "variable-reference") { + return void 0; + } + attributes.push({ + name: attributeName.value, + value: attributeValue.value + }); + index = attributeValue.nextIndex; + } else { + attributes.push({ name: attributeName.value, value: true }); + } + continue; + } + const optionName = readIdentifier(body, index); + if (!optionName) return void 0; + index = optionName.nextIndex; + index = skipWhitespace(body, index); + if (body[index] !== "=") return void 0; + index += 1; + index = skipWhitespace(body, index); + const optionValue = parseMarkupValue(body, index); + if (!optionValue) return void 0; + options.push({ + name: optionName.value, + value: optionValue.value + }); + index = optionValue.nextIndex; + } + return { name, options, attributes, standalone }; +} +function skipWhitespace(value, index) { + while (index < value.length && /\s/.test(value[index])) { + index += 1; + } + return index; +} +function readNameToken(value, index) { + return readIdentifier(value, index); +} +function readIdentifier(value, index) { + let cursor = index; + while (cursor < value.length && /\s/.test(value[cursor]) === false && value[cursor] !== "=" && value[cursor] !== "/" && value[cursor] !== "@") { + cursor += 1; + } + const parsed = value.slice(index, cursor); + if (parsed.length === 0) return void 0; + return { value: parsed, nextIndex: cursor }; +} +function parseMarkupValue(value, index) { + if (index >= value.length) return void 0; + if (value[index] === "|") { + let cursor = index + 1; + let literal2 = ""; + while (cursor < value.length) { + const char = value[cursor]; + if (char === "\\") { + const next = value[cursor + 1]; + if (next === "|" || next === "\\" || next === "}") { + literal2 += next; + cursor += 2; + continue; + } + literal2 += char; + cursor += 1; + continue; + } + if (char === "|") { + return { + value: { type: "literal", value: literal2 }, + nextIndex: cursor + 1 + }; + } + literal2 += char; + cursor += 1; + } + return void 0; + } + if (value[index] === "$") { + const variable = readIdentifier(value, index + 1); + if (!variable) return void 0; + return { + value: { type: "variable-reference", name: variable.value }, + nextIndex: variable.nextIndex + }; + } + const literal = readIdentifier(value, index); + if (!literal) return void 0; + return { + value: { type: "literal", value: literal.value }, + nextIndex: literal.nextIndex + }; +} function parseMatches(value) { const stripped = value.replace(" ", ""); const matches = []; @@ -2083,13 +2259,26 @@ function parseDeclaration(value) { } else if (value.startsWith("local")) { const match = value.match(/local (\w+) = (\w+): (\w+)(.*)/); const [, name, ref, fn, optionsString] = match; - const options = optionsString?.trim().split(/\s+/).map((pair) => { - const [key, value2] = pair.split("="); - return key && value2 ? { - name: key, - value: { type: "literal", value: value2 } - } : null; - }).filter(Boolean); + const options = []; + for (const optionMatch of (optionsString ?? "").matchAll( + /(\w+)\s*=\s*([^\s]+)/g + )) { + const optionName = optionMatch[1]; + const optionValue = optionMatch[2]; + if (!optionName || !optionValue) { + continue; + } + options.push({ + name: optionName, + value: optionValue.startsWith("$") && optionValue.length > 1 ? { + type: "variable-reference", + name: optionValue.slice(1) + } : { + type: "literal", + value: optionValue + } + }); + } return { type: "local-variable", name: name.trim(), @@ -2219,12 +2408,45 @@ function serializeVariants(bundle, message, variants) { function serializePattern(pattern) { let result = ""; for (const part of pattern) { - if (part.type === "text") { - result += escapePatternText(part.value); - } else if (part.arg.type === "variable-reference") { - result += `{${part.arg.name}}`; - } else { - throw new Error("Unsupported expression type"); + switch (part.type) { + case "text": + result += escapePatternText(part.value); + break; + case "expression": + if (part.arg.type === "variable-reference") { + result += `{${part.arg.name}}`; + break; + } + throw new Error("Unsupported expression type"); + case "markup-start": + result += serializeMarkup( + "#", + part.name, + part.options, + part.attributes, + false + ); + break; + case "markup-end": + result += serializeMarkup( + "/", + part.name, + part.options, + part.attributes, + false + ); + break; + case "markup-standalone": + result += serializeMarkup( + "#", + part.name, + part.options, + part.attributes, + true + ); + break; + default: + throw new Error("Unsupported pattern element type"); } } return result; @@ -2232,6 +2454,31 @@ function serializePattern(pattern) { function escapePatternText(value) { return value.replace(/\\/g, "\\\\").replace(/{/g, "\\{").replace(/}/g, "\\}"); } +function serializeMarkup(prefix, name, options, attributes, standalone) { + const serializedOptions = (options ?? []).map((option) => { + if (option.value.type === "variable-reference") { + return `${option.name}=$${option.value.name}`; + } + return `${option.name}=|${escapeMarkupLiteral(option.value.value)}|`; + }); + const serializedAttributes = (attributes ?? []).map((attribute) => { + if (attribute.value === true) { + return `@${attribute.name}`; + } + return `@${attribute.name}=|${escapeMarkupLiteral(attribute.value.value)}|`; + }); + const metadata = [...serializedOptions, ...serializedAttributes].join(" "); + if (metadata.length === 0) { + return standalone ? `{${prefix}${name}/}` : `{${prefix}${name}}`; + } + if (standalone) { + return `{${prefix}${name} ${metadata}/}`; + } + return `{${prefix}${name} ${metadata}}`; +} +function escapeMarkupLiteral(value) { + return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/}/g, "\\}"); +} function serializeMatcher(matches) { const parts = matches.sort((a, b) => a.key.localeCompare(b.key)).map( (match) => match.type === "literal-match" ? `${match.key}=${match.value}` : `${match.key}=*` @@ -2253,10 +2500,15 @@ function serializeDeclaration(declaration) { } if (declaration.value.annotation?.options) { for (const option of declaration.value?.annotation?.options ?? []) { - if (option.value.type !== "literal") { - throw new Error("Unsupported option type"); + if (option.value.type === "literal") { + result += ` ${option.name}=${option.value.value}`; + continue; + } + if (option.value.type === "variable-reference") { + result += ` ${option.name}=$${option.value.name}`; + continue; } - result += ` ${option.name}=${option.value.value}`; + throw new Error("Unsupported option type"); } } return result; diff --git a/frontend/src/lib/config/navigation-config.ts b/frontend/src/lib/config/navigation-config.ts index f2bf2478a9..7b9d8e6205 100644 --- a/frontend/src/lib/config/navigation-config.ts +++ b/frontend/src/lib/config/navigation-config.ts @@ -296,6 +296,13 @@ export const navigationItems: NavigationSections = { icon: ShieldAlertIcon, scope: 'global', requiredPermission: ['roles:read', 'roles:list'] + }, + { + title: m.diagnostics_title(), + url: '/settings/diagnostics', + icon: ActivityIcon, + scope: 'global', + requiredPermission: 'diagnostics:read' } ] } diff --git a/frontend/src/lib/services/diagnostics-service.ts b/frontend/src/lib/services/diagnostics-service.ts new file mode 100644 index 0000000000..81f05306c7 --- /dev/null +++ b/frontend/src/lib/services/diagnostics-service.ts @@ -0,0 +1,57 @@ +import BaseAPIService from './api-service'; +import type { Diagnostics, LogEntry, PprofProfile } from '$lib/types/diagnostics'; + +export default class DiagnosticsAPIService extends BaseAPIService { + /** One-shot runtime/memory/GC + WebSocket snapshot (used for initial paint). */ + async getDiagnostics(): Promise { + const res = await this.api.get('/diagnostics'); + return res.data as Diagnostics; + } + + /** Recent buffered backend log entries (oldest first). */ + async getRecentLogs(): Promise { + const res = await this.api.get('/diagnostics/logs'); + return (res.data ?? []) as LogEntry[]; + } + + /** Fetch a human-readable pprof dump (debug=2 text) for inline display. */ + async getDump(name: 'goroutine' | 'heap'): Promise { + const res = await this.api.get(`/debug/pprof/${name}`, { + params: { debug: 2 }, + responseType: 'text' + }); + return (res.data ?? '') as string; + } + + /** + * Download a raw pprof profile via the authed client (so bearer/API-key auth + * is attached) and trigger a browser save. `profile` and `trace` are + * time-sampled; pass an optional duration in seconds. + */ + async downloadProfile(profile: PprofProfile, seconds?: number): Promise { + const params: Record = {}; + if (profile === 'profile') params['seconds'] = seconds ?? 30; + if (profile === 'trace') params['seconds'] = seconds ?? 5; + + const defaultSeconds = profile === 'trace' ? 5 : 30; + const res = await this.api.get(`/debug/pprof/${profile}`, { + params, + responseType: 'blob', + // Sampled profiles block for their full duration. + timeout: profile === 'profile' || profile === 'trace' ? (seconds ?? defaultSeconds) * 1000 + 15000 : undefined + }); + + const blob = res.data as Blob; + const url = URL.createObjectURL(blob); + const ext = profile === 'trace' ? 'out' : 'pprof'; + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${profile}.${ext}`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } +} + +export const diagnosticsService = new DiagnosticsAPIService(); diff --git a/frontend/src/lib/types/diagnostics.ts b/frontend/src/lib/types/diagnostics.ts new file mode 100644 index 0000000000..e7ba07f7e7 --- /dev/null +++ b/frontend/src/lib/types/diagnostics.ts @@ -0,0 +1,82 @@ +// Mirrors backend/types/system/diagnostics.go (camelCase JSON keys). + +export interface RuntimeInfo { + goroutines: number; + wsWorkerGoroutines: number; + gomaxprocs: number; + numCpu: number; + goVersion: string; + os: string; + arch: string; + numCgoCall: number; + uptimeSeconds: number; +} + +export interface MemoryInfo { + alloc: number; + totalAlloc: number; + sys: number; + heapAlloc: number; + heapSys: number; + heapInuse: number; + heapIdle: number; + heapReleased: number; + heapObjects: number; + stackInuse: number; + stackSys: number; + mspanInuse: number; + mcacheInuse: number; + nextGc: number; + numGc: number; + numForcedGc: number; + gcCpuFraction: number; +} + +export interface GCInfo { + lastGc: string; + numGc: number; + pauseTotalNs: number; + recentPausesNs: number[]; +} + +export interface WebSocketConnectionInfo { + id: string; + kind: string; + envId?: string; + resourceId?: string; + clientIp?: string; + userId?: string; + userAgent?: string; + startedAt: string; +} + +export interface WebSocketMetricsSnapshot { + projectLogsActive: number; + containerLogsActive: number; + containerStats: number; + containerExec: number; + systemStats: number; + serviceLogsActive: number; +} + +export interface WebSocketDiagnostics { + snapshot: WebSocketMetricsSnapshot; + connections: WebSocketConnectionInfo[]; +} + +export interface Diagnostics { + timestamp: string; + runtime: RuntimeInfo; + memory: MemoryInfo; + gc: GCInfo; + websocket: WebSocketDiagnostics; +} + +export interface LogEntry { + time: string; + level: string; + message: string; + attrs?: Record; +} + +export type PprofProfile = 'heap' | 'goroutine' | 'allocs' | 'block' | 'mutex' | 'threadcreate' | 'profile' | 'trace'; diff --git a/frontend/src/lib/utils/ws.ts b/frontend/src/lib/utils/ws.ts index c6cef99f4c..17937d5f5a 100644 --- a/frontend/src/lib/utils/ws.ts +++ b/frontend/src/lib/utils/ws.ts @@ -1,4 +1,5 @@ import type { SystemStats } from '$lib/types/shared'; +import type { Diagnostics, LogEntry } from '$lib/types/diagnostics'; export interface ReconnectWSOptions { buildUrl: () => string | Promise; @@ -256,6 +257,52 @@ export function createContainerStatsWebSocket(opts: { }); } +export function createDiagnosticsWebSocket(opts: { + onMessage: (data: Diagnostics) => void; + onOpen?: () => void; + onClose?: () => void; + onError?: (err: Event | Error) => void; + maxBackoff?: number; +}) { + const buildUrl = () => { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${location.host}/api/diagnostics/stream`; + }; + + return new ReconnectingWebSocket({ + buildUrl, + parseMessage: (evt) => JSON.parse(evt.data as string) as Diagnostics, + onMessage: opts.onMessage, + onOpen: opts.onOpen, + onClose: opts.onClose, + onError: opts.onError, + maxBackoff: opts.maxBackoff + }); +} + +export function createBackendLogsWebSocket(opts: { + onMessage: (data: LogEntry) => void; + onOpen?: () => void; + onClose?: () => void; + onError?: (err: Event | Error) => void; + maxBackoff?: number; +}) { + const buildUrl = () => { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${location.host}/api/diagnostics/logs/stream`; + }; + + return new ReconnectingWebSocket({ + buildUrl, + parseMessage: (evt) => JSON.parse(evt.data as string) as LogEntry, + onMessage: opts.onMessage, + onOpen: opts.onOpen, + onClose: opts.onClose, + onError: opts.onError, + maxBackoff: opts.maxBackoff + }); +} + // --- Debounce helper --- export function debounced void>(func: T, delay: number) { diff --git a/frontend/src/routes/(app)/settings/diagnostics/+page.svelte b/frontend/src/routes/(app)/settings/diagnostics/+page.svelte new file mode 100644 index 0000000000..4af8e0ab5a --- /dev/null +++ b/frontend/src/routes/(app)/settings/diagnostics/+page.svelte @@ -0,0 +1,480 @@ + + +{#snippet row(label: string, value: string | number)} +
+ {label} + {value} +
+{/snippet} + +{#snippet sectionHeader(title: string, Icon: typeof ActivityIcon)} +
+ +

{title}

+
+{/snippet} + +
+ +
+
+
+ +
+
+

{m.diagnostics_title()}

+

{m.diagnostics_description()}

+
+
+ +
+ + + {paused ? m.diagnostics_status_paused() : connected ? m.diagnostics_status_live() : m.diagnostics_status_connecting()} + + + + +
+
+ + {#if error} +
+ {error} +
+ {/if} + + {#if diag} + +
+ + + + + + +
+ +
+ +
+ {@render sectionHeader(m.diagnostics_section_runtime(), CpuIcon)} +
+ {@render row(m.diagnostics_runtime_go_version(), diag.runtime.goVersion)} + {@render row(m.diagnostics_runtime_platform(), `${diag.runtime.os}/${diag.runtime.arch}`)} + {@render row(m.diagnostics_runtime_gomaxprocs(), diag.runtime.gomaxprocs)} + {@render row(m.diagnostics_runtime_num_cpu(), diag.runtime.numCpu)} + {@render row(m.diagnostics_runtime_goroutines(), fmtNum(diag.runtime.goroutines))} + {@render row(m.diagnostics_runtime_ws_workers(), fmtNum(diag.runtime.wsWorkerGoroutines))} + {@render row(m.diagnostics_runtime_cgo_calls(), fmtNum(diag.runtime.numCgoCall))} + {@render row(m.diagnostics_runtime_uptime(), fmtUptime(diag.runtime.uptimeSeconds))} +
+
+ + +
+ {@render sectionHeader(m.diagnostics_section_memory(), MemoryStickIcon)} + {#if heapBar} +
+
+
+
+
+
+ {m.diagnostics_mem_in_use()} + {fmtBytes(diag.memory.heapInuse)} + {m.diagnostics_mem_idle()} + {fmtBytes(diag.memory.heapIdle - diag.memory.heapReleased)} + {m.diagnostics_mem_released()} + {fmtBytes(diag.memory.heapReleased)} +
+ {/if} +
+ {@render row(m.diagnostics_mem_heap_alloc(), fmtBytes(diag.memory.heapAlloc))} + {@render row(m.diagnostics_mem_heap_sys(), fmtBytes(diag.memory.heapSys))} + {@render row(m.diagnostics_mem_heap_objects(), fmtNum(diag.memory.heapObjects))} + {@render row(m.diagnostics_mem_stack_in_use(), fmtBytes(diag.memory.stackInuse))} + {@render row(m.diagnostics_mem_total_alloc(), fmtBytes(diag.memory.totalAlloc))} + {@render row(m.diagnostics_mem_sys_total(), fmtBytes(diag.memory.sys))} + {@render row(m.diagnostics_mem_next_gc(), fmtBytes(diag.memory.nextGc))} + {@render row(m.diagnostics_mem_gc_cpu_fraction(), `${(diag.memory.gcCpuFraction * 100).toFixed(3)}%`)} +
+
+ + +
+ {@render sectionHeader(m.diagnostics_section_gc(), RefreshIcon)} +
+ {@render row(m.diagnostics_gc_total_cycles(), fmtNum(diag.gc.numGc))} + {@render row(m.diagnostics_gc_forced_cycles(), fmtNum(diag.memory.numForcedGc))} + {@render row(m.diagnostics_gc_total_pause(), fmtMs(diag.gc.pauseTotalNs))} + {@render row(m.diagnostics_gc_last(), diag.gc.lastGc ? new Date(diag.gc.lastGc).toLocaleTimeString() : '—')} +
+ {#if diag.gc.recentPausesNs?.length} +
+
{m.diagnostics_gc_recent_pauses()}
+
+ {#each diag.gc.recentPausesNs as p, i (i)} +
+ {/each} +
+
+ {/if} +
+ + +
+ {@render sectionHeader(m.diagnostics_section_websocket(), ConnectionIcon)} +
+ {@render row(m.diagnostics_ws_project_logs(), fmtNum(diag.websocket.snapshot.projectLogsActive))} + {@render row(m.diagnostics_ws_container_logs(), fmtNum(diag.websocket.snapshot.containerLogsActive))} + {@render row(m.diagnostics_ws_container_stats(), fmtNum(diag.websocket.snapshot.containerStats))} + {@render row(m.diagnostics_ws_terminals(), fmtNum(diag.websocket.snapshot.containerExec))} + {@render row(m.diagnostics_ws_system_stats(), fmtNum(diag.websocket.snapshot.systemStats))} + {@render row(m.diagnostics_ws_service_logs(), fmtNum(diag.websocket.snapshot.serviceLogsActive))} +
+
+
+ + + {#if diag.websocket.connections?.length} +
+ {@render sectionHeader(m.diagnostics_section_connections({ count: diag.websocket.connections.length }), ConnectionIcon)} +
+ + + + + + + + + + + + {#each diag.websocket.connections as c (c.id)} + + + + + + + + {/each} + +
{m.diagnostics_conn_kind()}{m.diagnostics_conn_resource()}{m.diagnostics_conn_client_ip()}{m.diagnostics_conn_user()}{m.diagnostics_conn_since()}
{wsKindLabels[c.kind] ?? c.kind}{c.resourceId || '—'}{c.clientIp || '—'}{c.userId || '—'} + {c.startedAt ? new Date(c.startedAt).toLocaleTimeString() : '—'} +
+
+
+ {/if} + + +
+ {@render sectionHeader(m.diagnostics_section_logs(), ActivityIcon)} + +
+ + +
+ {@render sectionHeader(m.diagnostics_section_dumps(), CpuIcon)} +
+ {#each [{ id: 'goroutine', label: m.diagnostics_dump_goroutine() }, { id: 'heap', label: m.diagnostics_dump_heap() }] as d (d.id)} + {@const name = d.id as 'goroutine' | 'heap'} + onDumpToggle(name, o)}> + + {d.label} + + + +
{dumpLoading[
+									name
+								]
+									? m.diagnostics_dump_loading()
+									: dumpText[name] || m.diagnostics_dump_empty()}
+
+
+ {/each} +
+
+ + +
+ {@render sectionHeader(m.diagnostics_section_profiles(), DownloadIcon)} +

{m.diagnostics_profiles_hint()}

+
+ {#each profiles as p (p.id)} + download(p.id)} + /> + {/each} +
+
+ {:else if !error} +
{m.diagnostics_loading()}
+ {/if} +
diff --git a/frontend/src/routes/(app)/settings/diagnostics/diagnostic-log-panel.svelte b/frontend/src/routes/(app)/settings/diagnostics/diagnostic-log-panel.svelte new file mode 100644 index 0000000000..25adda696f --- /dev/null +++ b/frontend/src/routes/(app)/settings/diagnostics/diagnostic-log-panel.svelte @@ -0,0 +1,162 @@ + + +
+
+ + + {connected ? m.diagnostics_logs_streaming() : m.diagnostics_logs_disconnected()} + + {m.diagnostics_logs_count({ count: filtered.length })} + +
+ + + + (logs = [])} + /> +
+
+ +
+ {#if filtered.length === 0} +
{m.diagnostics_logs_empty()}
+ {:else} + {#each filtered as entry (logKey(entry))} +
+ {fmtTime(entry.time)} + {entry.level} + + {entry.message} + {#if entry.attrs}{attrsText(entry.attrs)}{/if} + +
+ {/each} + {/if} +
+
diff --git a/frontend/src/routes/(app)/settings/diagnostics/diagnostic-stat.svelte b/frontend/src/routes/(app)/settings/diagnostics/diagnostic-stat.svelte new file mode 100644 index 0000000000..151f3846f0 --- /dev/null +++ b/frontend/src/routes/(app)/settings/diagnostics/diagnostic-stat.svelte @@ -0,0 +1,29 @@ + + +
+
+ +
+
+
{label}
+
+ {value} + {#if unit}{unit}{/if} +
+ {#if sub}
{sub}
{/if} +
+
diff --git a/types/system/diagnostics.go b/types/system/diagnostics.go new file mode 100644 index 0000000000..e6dbce0855 --- /dev/null +++ b/types/system/diagnostics.go @@ -0,0 +1,102 @@ +package system + +import "time" + +// Diagnostics is a point-in-time snapshot of the Go runtime, garbage collector, +// and active WebSocket connections. It is returned by the diagnostics REST +// endpoint and pushed over the live diagnostics WebSocket stream. +type Diagnostics struct { + // Timestamp is when the snapshot was taken. + // + // Required: true + Timestamp time.Time `json:"timestamp"` + // Runtime holds Go runtime and scheduler counters. + // + // Required: true + Runtime RuntimeInfo `json:"runtime"` + // Memory holds a subset of runtime.MemStats. + // + // Required: true + Memory MemoryInfo `json:"memory"` + // GC holds garbage-collector statistics. + // + // Required: true + GC GCInfo `json:"gc"` + // WebSocket holds active WebSocket connection metrics. + // + // Required: true + WebSocket WebSocketDiagnostics `json:"websocket"` +} + +// RuntimeInfo describes the Go runtime, build, and scheduler state. +type RuntimeInfo struct { + Goroutines int `json:"goroutines"` + WSWorkerGoroutines int `json:"wsWorkerGoroutines"` + GOMAXPROCS int `json:"gomaxprocs"` + NumCPU int `json:"numCpu"` + GoVersion string `json:"goVersion"` + OS string `json:"os"` + Arch string `json:"arch"` + NumCgoCall int64 `json:"numCgoCall"` + UptimeSeconds int64 `json:"uptimeSeconds"` +} + +// MemoryInfo is the subset of runtime.MemStats surfaced in diagnostics. All +// byte counts are raw bytes. +type MemoryInfo struct { + Alloc uint64 `json:"alloc"` + TotalAlloc uint64 `json:"totalAlloc"` + Sys uint64 `json:"sys"` + HeapAlloc uint64 `json:"heapAlloc"` + HeapSys uint64 `json:"heapSys"` + HeapInuse uint64 `json:"heapInuse"` + HeapIdle uint64 `json:"heapIdle"` + HeapReleased uint64 `json:"heapReleased"` + HeapObjects uint64 `json:"heapObjects"` + StackInuse uint64 `json:"stackInuse"` + StackSys uint64 `json:"stackSys"` + MSpanInuse uint64 `json:"mspanInuse"` + MCacheInuse uint64 `json:"mcacheInuse"` + NextGC uint64 `json:"nextGc"` + NumGC uint32 `json:"numGc"` + NumForcedGC uint32 `json:"numForcedGc"` + GCCPUFraction float64 `json:"gcCpuFraction"` +} + +// GCInfo holds garbage-collector statistics from runtime/debug.ReadGCStats. +type GCInfo struct { + // LastGC is the time of the most recent collection. + LastGC time.Time `json:"lastGc"` + // NumGC is the total number of completed GC cycles. + NumGC int64 `json:"numGc"` + // PauseTotalNs is the cumulative stop-the-world pause time in nanoseconds. + PauseTotalNs int64 `json:"pauseTotalNs"` + // RecentPausesNs lists the most recent GC pause durations (ns), newest first. + RecentPausesNs []int64 `json:"recentPausesNs"` +} + +// WebSocketDiagnostics aggregates the active WebSocket connection counts and the +// list of currently-tracked connections. +type WebSocketDiagnostics struct { + Snapshot WebSocketMetricsSnapshot `json:"snapshot"` + Connections []WebSocketConnectionInfo `json:"connections"` +} + +// LogEntry is a single captured backend log record, exposed via the recent-logs +// endpoint and the live log WebSocket stream. +type LogEntry struct { + // Time is when the record was emitted. + // + // Required: true + Time time.Time `json:"time"` + // Level is the slog level name (DEBUG, INFO, WARN, ERROR). + // + // Required: true + Level string `json:"level"` + // Message is the log message. + // + // Required: true + Message string `json:"message"` + // Attrs holds the record's structured attributes, if any. + Attrs map[string]any `json:"attrs,omitempty"` +} From dd1d1b483d5abc3388350af04f14075b244e18df Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 15:11:52 -0500 Subject: [PATCH 27/40] fix: make updater service skip swarm containers and make checks more robust (#2777) --- backend/internal/di/providers.go | 4 +- backend/internal/di/wire_gen.go | 2 +- .../internal/services/image_update_service.go | 17 +++ backend/internal/services/project_service.go | 20 +-- .../internal/services/project_service_test.go | 116 +++++++++++++++++ .../services/system_upgrade_service.go | 19 +-- backend/internal/services/updater_service.go | 20 +++ .../internal/services/updater_service_test.go | 118 ++++++++++++++++++ backend/internal/services/version_service.go | 57 ++++++--- .../internal/services/version_service_test.go | 108 ++++++++++++++++ backend/pkg/libarcane/imageupdate/labels.go | 23 ++++ .../pkg/libarcane/imageupdate/labels_test.go | 52 ++++++++ go.work.sum | 4 +- 13 files changed, 524 insertions(+), 36 deletions(-) create mode 100644 backend/internal/services/version_service_test.go diff --git a/backend/internal/di/providers.go b/backend/internal/di/providers.go index 4d4e8b5fb4..6ab8867c61 100644 --- a/backend/internal/di/providers.go +++ b/backend/internal/di/providers.go @@ -87,8 +87,8 @@ func provideResourcesFSInternal() embed.FS { // wire_gen.go call them in a normal build. // provideVersionServiceInternal supplies the bool/string scalars wire can't inject by type. -func provideVersionServiceInternal(httpClient *http.Client, cfg *config.Config, registry *services.ContainerRegistryService, docker *services.DockerClientService) *services.VersionService { - return services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, registry, docker) +func provideVersionServiceInternal(httpClient *http.Client, cfg *config.Config, registry *services.ContainerRegistryService, docker *services.DockerClientService, imageUpdate *services.ImageUpdateService) *services.VersionService { + return services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, registry, docker, imageUpdate) } // provideGitRepositoryServiceInternal supplies cfg.GitWorkDir (bare string). diff --git a/backend/internal/di/wire_gen.go b/backend/internal/di/wire_gen.go index 3e07a1753e..dc74b265c0 100644 --- a/backend/internal/di/wire_gen.go +++ b/backend/internal/di/wire_gen.go @@ -58,7 +58,7 @@ func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config oidcService := services.NewOidcService(authService, settingsService, cfg, httpClient) templateService := services.NewTemplateService(ctx, db, httpClient, settingsService) systemService := services.NewSystemService(db, dockerClientService, containerService, imageService, volumeService, networkService, settingsService, activityService) - versionService := provideVersionServiceInternal(httpClient, cfg, containerRegistryService, dockerClientService) + versionService := provideVersionServiceInternal(httpClient, cfg, containerRegistryService, dockerClientService, imageUpdateService) systemUpgradeService := services.NewSystemUpgradeService(dockerClientService, versionService, eventService, settingsService) diagnosticsService := services.NewDiagnosticsService() updaterService := provideUpdaterServiceInternal(db, settingsService, dockerClientService, projectService, imageUpdateService, containerRegistryService, eventService, imageService, notificationService, systemUpgradeService, activityService) diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index f0ae82abeb..b7f677c114 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -804,6 +804,23 @@ func (s *ImageUpdateService) MarkImageRefUpToDateAfterPull(ctx context.Context, }) } +func (s *ImageUpdateService) getStoredUpdateByImageIDInternal(ctx context.Context, imageID string) (*models.ImageUpdateRecord, bool, error) { + imageID = strings.TrimSpace(imageID) + if s == nil || s.db == nil || imageID == "" { + return nil, false, nil + } + + var record models.ImageUpdateRecord + if err := s.db.WithContext(ctx).Where("id = ?", imageID).First(&record).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, fmt.Errorf("get stored image update by image id: %w", err) + } + + return &record, true, nil +} + // GetUnnotifiedUpdates returns a map of image IDs that have updates but haven't been notified yet func (s *ImageUpdateService) GetUnnotifiedUpdates(ctx context.Context) (map[string]*models.ImageUpdateRecord, error) { var records []models.ImageUpdateRecord diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index e11180441d..9bd012736a 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -202,7 +202,11 @@ const ( imagePullModeAlways ) -var composePullProjectServicesInternal = projects.ComposePull +var ( + composePullProjectServicesInternal = projects.ComposePull + composeStopProjectServicesInternal = projects.ComposeStop + composeUpProjectServicesInternal = projects.ComposeUp +) func resolveServiceImagePullMode(svc composetypes.ServiceConfig) imagePullMode { rawPolicy := strings.ToLower(strings.TrimSpace(svc.PullPolicy)) @@ -744,6 +748,7 @@ func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID st if err != nil { return err } + previousStatus := projectFromDb.Status // 1. Load project compProj, _, err := s.loadComposeProjectForProjectInternal(ctx, projectFromDb, nil) @@ -759,21 +764,22 @@ func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID st // 3. Pull images for specific services writeProjectProgressInternal(ctx, "Pulling updated service images", 20, "pull") if err := s.composePullSelectedServicesInternal(ctx, compProj, servicesToUpdate); err != nil { - slog.WarnContext(ctx, "compose pull failed, continuing", "error", err) + if statusErr := s.updateProjectStatusInternal(ctx, projectID, previousStatus); statusErr != nil { + slog.ErrorContext(ctx, "UpdateProjectServices: failed to restore project status after compose pull failure", "projectID", projectID, "error", statusErr) + } + return fmt.Errorf("pull updated service images: %w", err) } // 4. Stop specific services writeProjectProgressInternal(ctx, "Stopping selected services", 45, "stop") - if err := projects.ComposeStop(ctx, compProj, servicesToUpdate); err != nil { + if err := composeStopProjectServicesInternal(ctx, compProj, servicesToUpdate); err != nil { slog.WarnContext(ctx, "compose stop failed, continuing", "error", err) } // 5. Up specific services writeProjectProgressInternal(ctx, "Starting selected services", 70, "up") - if err := projects.ComposeUp(ctx, compProj, servicesToUpdate, false, false); err != nil { - if statusErr := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped); statusErr != nil { - slog.ErrorContext(ctx, "UpdateProjectServices: failed to set stopped status after compose up failure", "projectID", projectID, "error", statusErr) - } + if err := composeUpProjectServicesInternal(ctx, compProj, servicesToUpdate, false, true); err != nil { + s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) return fmt.Errorf("failed to up services: %w", err) } diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index 25b5726f0d..a352bf2acf 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -895,6 +895,122 @@ func TestProjectService_ComposePullSelectedServicesInternal_LeavesRecordsWhenPul assert.Zero(t, count) } +func TestProjectService_UpdateProjectServicesHardFailsWhenPullFailsInternal(t *testing.T) { + ctx := context.Background() + db := setupProjectTestDB(t) + projectsDir := t.TempDir() + t.Setenv("PROJECTS_DIRECTORY", projectsDir) + + settingsService, err := NewSettingsService(ctx, db) + require.NoError(t, err) + require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) + + projectPath := createComposeProjectDir(t, projectsDir, "compose-update-pull-fail") + require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: registry.example.com/team/app:9.9.9\n"), 0o644)) + + projectRecord := &models.Project{ + BaseModel: models.BaseModel{ID: "project-update-pull-fail"}, + Name: "compose-update-pull-fail", + DirName: ptr("compose-update-pull-fail"), + Path: projectPath, + Status: models.ProjectStatusRunning, + } + require.NoError(t, db.Create(projectRecord).Error) + require.NoError(t, db.Create(&models.ImageUpdateRecord{ + ID: "sha256:selected-old", + Repository: "registry.example.com/team/app", + Tag: "9.9.9", + HasUpdate: true, + UpdateType: models.UpdateTypeDigest, + CurrentVersion: "9.9.9", + CheckTime: time.Now().UTC().Add(-time.Hour), + }).Error) + + originalComposePull := composePullProjectServicesInternal + originalComposeUp := composeUpProjectServicesInternal + t.Cleanup(func() { + composePullProjectServicesInternal = originalComposePull + composeUpProjectServicesInternal = originalComposeUp + }) + + composePullProjectServicesInternal = func(context.Context, *composetypes.Project, []string) error { + return errors.New("compose pull failed") + } + upCalled := false + composeUpProjectServicesInternal = func(context.Context, *composetypes.Project, []string, bool, bool) error { + upCalled = true + return errors.New("compose up should not run") + } + + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) + require.Error(t, err) + assert.ErrorContains(t, err, "pull updated service images") + assert.False(t, upCalled, "compose up must not run after a pull failure") + + var persistedProject models.Project + require.NoError(t, db.WithContext(ctx).Where("id = ?", projectRecord.ID).First(&persistedProject).Error) + assert.Equal(t, models.ProjectStatusRunning, persistedProject.Status) + + var persistedRecord models.ImageUpdateRecord + require.NoError(t, db.WithContext(ctx).Where("id = ?", "sha256:selected-old").First(&persistedRecord).Error) + assert.True(t, persistedRecord.HasUpdate) +} + +func TestProjectService_UpdateProjectServicesForcesRecreateInternal(t *testing.T) { + ctx := context.Background() + db := setupProjectTestDB(t) + projectsDir := t.TempDir() + t.Setenv("PROJECTS_DIRECTORY", projectsDir) + + settingsService, err := NewSettingsService(ctx, db) + require.NoError(t, err) + require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) + + projectPath := createComposeProjectDir(t, projectsDir, "compose-update-force") + require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: registry.example.com/team/app:9.9.9\n"), 0o644)) + + projectRecord := &models.Project{ + BaseModel: models.BaseModel{ID: "project-update-force"}, + Name: "compose-update-force", + DirName: ptr("compose-update-force"), + Path: projectPath, + Status: models.ProjectStatusRunning, + } + require.NoError(t, db.Create(projectRecord).Error) + + originalComposePull := composePullProjectServicesInternal + originalComposeStop := composeStopProjectServicesInternal + originalComposeUp := composeUpProjectServicesInternal + t.Cleanup(func() { + composePullProjectServicesInternal = originalComposePull + composeStopProjectServicesInternal = originalComposeStop + composeUpProjectServicesInternal = originalComposeUp + }) + + composePullProjectServicesInternal = func(context.Context, *composetypes.Project, []string) error { + return nil + } + composeStopProjectServicesInternal = func(context.Context, *composetypes.Project, []string) error { + return nil + } + upCalled := false + forceRecreate := false + composeUpProjectServicesInternal = func(_ context.Context, _ *composetypes.Project, services []string, removeOrphans bool, force bool) error { + upCalled = true + forceRecreate = force + assert.Equal(t, []string{"app"}, services) + assert.False(t, removeOrphans) + return errors.New("compose up failed after assertion") + } + + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) + require.Error(t, err) + assert.True(t, upCalled) + assert.True(t, forceRecreate, "service updates must force recreate after pulling the updated image") +} + func TestProjectService_UpdateProject_RenamesDirectoryWhenNameChanges(t *testing.T) { db := setupProjectTestDB(t) ctx := context.Background() diff --git a/backend/internal/services/system_upgrade_service.go b/backend/internal/services/system_upgrade_service.go index 5ef6490010..cf0a59f69b 100644 --- a/backend/internal/services/system_upgrade_service.go +++ b/backend/internal/services/system_upgrade_service.go @@ -21,7 +21,7 @@ import ( "github.com/moby/moby/client" ) -var ArcaneUpgraderImage = "ghcr.io/getarcaneapp/arcane:latest" +const defaultArcaneUpgraderImageInternal = "ghcr.io/getarcaneapp/arcane:latest" type SystemUpgradeService struct { upgrading atomic.Bool @@ -114,14 +114,15 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo // Use the same image reference as the currently running Arcane container for the upgrader. // This avoids mismatches where a newer/older upgrader CLI expects different behavior. + upgraderImage := defaultArcaneUpgraderImageInternal if currentContainer.Config != nil { if img := strings.TrimSpace(currentContainer.Config.Image); img != "" { - ArcaneUpgraderImage = img + upgraderImage = img } } - slog.Debug("Using upgrader image", "image", ArcaneUpgraderImage) + slog.Debug("Using upgrader image", "image", upgraderImage) - slog.Info("Spawning upgrade CLI command", "containerName", containerName, "upgraderImage", ArcaneUpgraderImage) + slog.Info("Spawning upgrade CLI command", "containerName", containerName, "upgraderImage", upgraderImage) // Spawn the upgrade command in a detached container // This will run independently of the current container @@ -131,16 +132,16 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo } // Pull the upgrader image first to ensure it exists - slog.Info("Pulling upgrader image", "image", ArcaneUpgraderImage) + slog.Info("Pulling upgrader image", "image", upgraderImage) settings := s.settingsService.GetSettingsConfig() pullCtx, pullCancel := timeouts.WithTimeout(ctx, settings.DockerImagePullTimeout.AsInt(), timeouts.DefaultDockerImagePull) defer pullCancel() - pullReader, err := dockerClient.ImagePull(pullCtx, ArcaneUpgraderImage, client.ImagePullOptions{}) + pullReader, err := dockerClient.ImagePull(pullCtx, upgraderImage, client.ImagePullOptions{}) if err != nil { if errors.Is(pullCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("upgrader image pull timed out for %s (increase DOCKER_IMAGE_PULL_TIMEOUT or setting)", ArcaneUpgraderImage) + return fmt.Errorf("upgrader image pull timed out for %s (increase DOCKER_IMAGE_PULL_TIMEOUT or setting)", upgraderImage) } return fmt.Errorf("pull upgrader image: %w", err) } @@ -152,7 +153,7 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo if closeErr := pullReader.Close(); closeErr != nil { slog.Warn("Failed to close upgrader image pull reader", "error", closeErr) } - slog.Info("Upgrader image pulled successfully", "image", ArcaneUpgraderImage) + slog.Info("Upgrader image pulled successfully", "image", upgraderImage) // Try to get the /app/data mount from current container so upgrade logs persist. appDataMount := dockerutils.MountForDestination(currentContainer.Mounts, "/app/data", "/app/data") @@ -180,7 +181,7 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo } config := &containertypes.Config{ - Image: ArcaneUpgraderImage, + Image: upgraderImage, Cmd: []string{binaryPath, "upgrade", "--container", containerName}, Env: runtimeOptions.ContainerEnv, Labels: map[string]string{ diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index 87e0b202e6..b868b63102 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -589,6 +589,21 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID return out, nil } + if libupdater.IsSwarmTask(labels) && !isArcaneContainer { + slog.InfoContext(ctx, "UpdateSingleContainer: skipping swarm task container", "containerID", containerID) + out.Items = append(out.Items, updater.ResourceResult{ + ResourceID: targetContainer.ID, + ResourceType: "container", + ResourceName: containerName, + Status: "skipped", + Error: "swarm service; update at the service level", + }) + out.Skipped++ + out.Checked = 1 + out.Duration = time.Since(start).String() + return out, nil + } + // Resolve the best pullable image reference for this container. configImageRef := "" if inspectBefore.Config != nil { @@ -1419,6 +1434,11 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID continue } + if libupdater.IsSwarmTask(c.Labels) && !libupdater.IsArcaneContainer(c.Labels) { + slog.DebugContext(ctx, "restartContainersUsingOldIDs: skipping swarm task container", "containerId", c.ID, "names", c.Names) + continue + } + // Skip the Docker socket proxy container to preserve Arcane's Docker connectivity (#1881) if dockerProxyName != "" { isProxy := false diff --git a/backend/internal/services/updater_service_test.go b/backend/internal/services/updater_service_test.go index 950afa2418..c1e63877cd 100644 --- a/backend/internal/services/updater_service_test.go +++ b/backend/internal/services/updater_service_test.go @@ -270,6 +270,124 @@ func TestUpdaterService_CLICalledWithSystemUser(t *testing.T) { assert.Equal(t, systemUser.Username, mockUpgrade.capturedUser.Username) } +func TestUpdaterService_UpdateSingleContainerSkipsSwarmTaskInternal(t *testing.T) { + ctx := context.Background() + const containerID = "swarm-task-1" + labels := map[string]string{ + libupdater.LabelSwarmServiceID: "service-1", + libupdater.LabelSwarmServiceName: "web", + } + pullCalled := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/containers/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode([]container.Summary{ + { + ID: containerID, + Names: []string{"/web.1.task"}, + Image: "nginx:latest", + ImageID: "sha256:old-image", + Labels: labels, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(container.InspectResponse{ + ID: containerID, + Image: "sha256:old-image", + Config: &container.Config{ + Image: "nginx:latest", + Labels: labels, + }, + })) + case strings.HasSuffix(r.URL.Path, "/images/create"): + pullCalled = true + http.Error(w, "unexpected pull", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dcli, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost("tcp://"+srv.Listener.Addr().String()), + dockerclient.WithVersion("1.46"), + ) + require.NoError(t, err) + + db := setupActivityServiceTestDBInternal(t) + svc := NewUpdaterService(nil, nil, &DockerClientService{client: dcli, config: &config.Config{}}, nil, nil, nil, nil, nil, nil, nil, NewActivityService(db)) + + out, err := svc.UpdateSingleContainer(ctx, containerID) + require.NoError(t, err) + require.NotNil(t, out) + require.Len(t, out.Items, 1) + assert.Equal(t, "skipped", out.Items[0].Status) + assert.Contains(t, out.Items[0].Error, "swarm service") + assert.False(t, pullCalled, "swarm task update must not pull or recreate the task container") +} + +func TestUpdaterService_RestartContainersUsingOldIDsSkipsSwarmTaskInternal(t *testing.T) { + ctx := context.Background() + const containerID = "swarm-task-1" + labels := map[string]string{ + libupdater.LabelSwarmServiceID: "service-1", + libupdater.LabelSwarmServiceName: "web", + } + stopCalled := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/containers/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode([]container.Summary{ + { + ID: containerID, + Names: []string{"/web.1.task"}, + Image: "nginx:latest", + ImageID: "sha256:old-image", + Labels: labels, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(container.InspectResponse{ + ID: containerID, + Name: "/web.1.task", + Image: "sha256:old-image", + Config: &container.Config{ + Image: "nginx:latest", + Labels: labels, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/stop"): + stopCalled = true + http.Error(w, "unexpected stop", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dcli, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost("tcp://"+srv.Listener.Addr().String()), + dockerclient.WithVersion("1.46"), + ) + require.NoError(t, err) + + settingsDB := setupUpdaterServiceSettingsDB(t) + settings, err := NewSettingsService(ctx, settingsDB) + require.NoError(t, err) + svc := NewUpdaterService(nil, settings, &DockerClientService{client: dcli, config: &config.Config{}}, nil, nil, nil, nil, nil, nil, nil, nil) + + results, err := svc.restartContainersUsingOldIDs(ctx, map[string]string{"sha256:old-image": "nginx:latest"}, nil) + require.NoError(t, err) + assert.Empty(t, results) + assert.False(t, stopCalled, "swarm task update must not stop the orchestrator-owned task container") +} + // TestUpdaterService_UpgradeServiceNotNilCheck verifies the nil check logic func TestUpdaterService_UpgradeServiceNotNilCheck(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/services/version_service.go b/backend/internal/services/version_service.go index bfd64e9f22..e164803a4b 100644 --- a/backend/internal/services/version_service.go +++ b/backend/internal/services/version_service.go @@ -43,9 +43,10 @@ type VersionService struct { revision string containerRegistryService *ContainerRegistryService dockerService *DockerClientService + imageUpdateService *ImageUpdateService } -func NewVersionService(httpClient *http.Client, disabled bool, version string, revision string, containerRegistryService *ContainerRegistryService, dockerService *DockerClientService) *VersionService { +func NewVersionService(httpClient *http.Client, disabled bool, version string, revision string, containerRegistryService *ContainerRegistryService, dockerService *DockerClientService, imageUpdateService *ImageUpdateService) *VersionService { if httpClient == nil { httpClient = http.DefaultClient } @@ -57,6 +58,7 @@ func NewVersionService(httpClient *http.Client, disabled bool, version string, r revision: revision, containerRegistryService: containerRegistryService, dockerService: dockerService, + imageUpdateService: imageUpdateService, } } @@ -221,7 +223,7 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { ver := s.normalizeVersion(s.version) // Always detect current image info - currentTag, currentDigest, currentImageRef := s.detectCurrentImageInfo(ctx) + currentTag, currentDigest, currentImageRef, currentImageID := s.detectCurrentImageInfo(ctx) // Build base info struct (always populated) info := &version.Info{ @@ -243,6 +245,8 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { return info } + semverUpdateAvailable := false + // For semver versions, check GitHub releases if isSemver { rel, err := s.getLatestReleaseInternal(ctx) @@ -250,19 +254,16 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { if err == nil || errors.As(err, &staleErr) { if rel.TagName != "" { info.NewestVersion = rel.TagName - info.UpdateAvailable = s.IsNewer(rel.TagName, ver) + semverUpdateAvailable = s.IsNewer(rel.TagName, ver) info.ReleaseURL = s.ReleaseURL(rel.TagName) info.ReleaseNotes = rel.Body info.ReleasedAt = rel.PublishedAt } } - return info } - // For non-semver versions (like "next"), check digest-based updates - if currentTag != "" && currentDigest != "" && currentImageRef != "" && s.containerRegistryService != nil { - updateAvailable, latestDigest := s.checkDigestBasedUpdate(ctx, currentTag, currentDigest, currentImageRef) - info.UpdateAvailable = updateAvailable + digestUpdateAvailable, latestDigest := s.storedOrDigestBasedUpdateInternal(ctx, currentImageID, currentTag, currentDigest, currentImageRef) + if latestDigest != "" { info.NewestDigest = latestDigest } @@ -278,9 +279,27 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { } } + info.UpdateAvailable = semverUpdateAvailable || (!isSemver && digestUpdateAvailable) return info } +func (s *VersionService) storedOrDigestBasedUpdateInternal(ctx context.Context, currentImageID, currentTag, currentDigest, currentImageRef string) (bool, string) { + if s.imageUpdateService != nil && strings.TrimSpace(currentImageID) != "" { + record, found, err := s.imageUpdateService.getStoredUpdateByImageIDInternal(ctx, currentImageID) + if err != nil { + slog.WarnContext(ctx, "Failed to read stored Arcane image update state", "imageID", currentImageID, "error", err) + } else if found { + return record.HasUpdate, stringPtrToString(record.LatestDigest) + } + } + + if currentTag != "" && currentDigest != "" && currentImageRef != "" && s.containerRegistryService != nil { + return s.checkDigestBasedUpdate(ctx, currentTag, currentDigest, currentImageRef) + } + + return false, "" +} + func parseEnabledFeatures() []string { raw := strings.TrimSpace(buildables.EnabledFeatures) if raw == "" { @@ -304,44 +323,50 @@ func parseEnabledFeatures() []string { } // detectCurrentImageInfo attempts to detect the current container's image tag and digest -func (s *VersionService) detectCurrentImageInfo(ctx context.Context) (tag string, digest string, imageRef string) { +func (s *VersionService) detectCurrentImageInfo(ctx context.Context) (tag string, digest string, imageRef string, imageID string) { if s.dockerService == nil { slog.Debug("detectCurrentImageInfo: dockerService is nil") - return "", "", "" + return "", "", "", "" } dockerClient, err := s.dockerService.GetClient(ctx) if err != nil { slog.Debug("detectCurrentImageInfo: failed to get docker client", "error", err) - return "", "", "" + return "", "", "", "" } containerId := s.detectContainerID(ctx, dockerClient) if containerId == "" { slog.Debug("detectCurrentImageInfo: could not detect container ID") - return "", "", "" + return "", "", "", "" } slog.Debug("detectCurrentImageInfo: detected container", "containerId", containerId) inspectResult, err := libarcane.ContainerInspectWithCompatibility(ctx, dockerClient, containerId, client.ContainerInspectOptions{}) if err != nil { slog.Debug("detectCurrentImageInfo: failed to inspect container", "containerId", containerId, "error", err) - return "", "", "" + return "", "", "", "" } container := inspectResult.Container + imageID = container.Image + + configImage := "" + if container.Config != nil { + configImage = container.Config.Image + } // Parse tag from container config image (user-specified reference) - tag = s.extractTagFromImageRef(container.Config.Image) + tag = s.extractTagFromImageRef(configImage) // Get digest and normalized imageRef from container image imageRef, digest = s.extractImageDetails(ctx, dockerClient, container) // Fallback to container config image if RepoDigests didn't provide imageRef if imageRef == "" { - imageRef = s.normalizeImageRef(container.Config.Image) + imageRef = s.normalizeImageRef(configImage) } - return tag, digest, imageRef + return tag, digest, imageRef, imageID } // detectContainerID tries to get the current container ID, falling back to label-based detection diff --git a/backend/internal/services/version_service_test.go b/backend/internal/services/version_service_test.go new file mode 100644 index 0000000000..f80bcd8584 --- /dev/null +++ b/backend/internal/services/version_service_test.go @@ -0,0 +1,108 @@ +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/models" + libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" + "github.com/moby/moby/api/types/container" + dockertypesimage "github.com/moby/moby/api/types/image" + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionService_GetAppVersionInfoDoesNotUseStoredDigestUpdateForSemverBuildInternal(t *testing.T) { + ctx := context.Background() + db := setupImageUpdateTestDB(t) + + const ( + containerID = "arcane-container-1234567890" + imageID = "sha256:arcane-image" + imageRef = "ghcr.io/getarcaneapp/arcane:latest" + ) + currentDigest := digest.FromString("current-arcane").String() + latestDigest := digest.FromString("latest-arcane").String() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/containers/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode([]container.Summary{ + { + ID: containerID, + State: container.StateRunning, + Labels: map[string]string{ + libupdater.LabelArcane: "true", + }, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(container.InspectResponse{ + ID: containerID, + Image: imageID, + Config: &container.Config{ + Image: imageRef, + Labels: map[string]string{ + libupdater.LabelArcane: "true", + }, + }, + })) + case strings.Contains(r.URL.Path, "/images/") && strings.HasSuffix(r.URL.Path, "/json"): + encodedRef := strings.TrimSuffix(r.URL.Path[strings.LastIndex(r.URL.Path, "/images/")+len("/images/"):], "/json") + _, err := url.PathUnescape(encodedRef) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(dockertypesimage.InspectResponse{ + ID: imageID, + RepoTags: []string{imageRef}, + RepoDigests: []string{"ghcr.io/getarcaneapp/arcane@" + currentDigest}, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + require.NoError(t, db.WithContext(ctx).Create(&models.ImageUpdateRecord{ + ID: imageID, + Repository: "ghcr.io/getarcaneapp/arcane", + Tag: "latest", + HasUpdate: true, + UpdateType: models.UpdateTypeDigest, + CurrentVersion: "latest", + CurrentDigest: ¤tDigest, + LatestDigest: &latestDigest, + CheckTime: time.Now().UTC(), + }).Error) + + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("unavailable")), + Request: req, + }, nil + })} + dockerService := &DockerClientService{client: newTestDockerClient(t, server)} + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) + svc := NewVersionService(httpClient, false, "1.2.3", "revision", nil, dockerService, imageUpdateService) + + info := svc.GetAppVersionInfo(ctx) + + require.NotNil(t, info) + assert.True(t, info.IsSemverVersion) + assert.False(t, info.UpdateAvailable) + assert.Equal(t, latestDigest, info.NewestDigest) + assert.Equal(t, currentDigest, info.CurrentDigest) +} diff --git a/backend/pkg/libarcane/imageupdate/labels.go b/backend/pkg/libarcane/imageupdate/labels.go index 68e020c6cf..c9e2d56096 100644 --- a/backend/pkg/libarcane/imageupdate/labels.go +++ b/backend/pkg/libarcane/imageupdate/labels.go @@ -8,6 +8,10 @@ const ( LabelArcaneAgent = "com.getarcaneapp.arcane.agent" // Identifies an Arcane agent container LabelUpdater = "com.getarcaneapp.arcane.updater" // Enable/disable updates (true/false) + // LabelSwarmServiceID and LabelSwarmServiceName identify Docker Swarm task containers. + LabelSwarmServiceID = "com.docker.swarm.service.id" + LabelSwarmServiceName = "com.docker.swarm.service.name" + // LabelDependsOn and the constants below are update-dependency labels. LabelDependsOn = "com.getarcaneapp.arcane.depends-on" // Comma-separated list of container names this depends on LabelStopSignal = "com.getarcaneapp.arcane.stop-signal" // Custom stop signal (e.g., SIGINT) @@ -62,6 +66,11 @@ func IsUpdateDisabled(labels map[string]string) bool { return false } +// IsSwarmTask checks if the labels identify a Docker Swarm task container. +func IsSwarmTask(labels map[string]string) bool { + return hasNonEmptyLabelInternal(labels, LabelSwarmServiceID) || hasNonEmptyLabelInternal(labels, LabelSwarmServiceName) +} + // GetStopSignal returns the custom stop signal if set, otherwise empty string func GetStopSignal(labels map[string]string) string { if labels == nil { @@ -89,6 +98,20 @@ func hasTruthyLabelInternal(labels map[string]string, target string) bool { return false } +func hasNonEmptyLabelInternal(labels map[string]string, target string) bool { + if labels == nil { + return false + } + + for k, v := range labels { + if strings.EqualFold(k, target) && strings.TrimSpace(v) != "" { + return true + } + } + + return false +} + func isTruthyLabelValueInternal(v string) bool { switch strings.TrimSpace(strings.ToLower(v)) { case "true", "1", "yes", "on": diff --git a/backend/pkg/libarcane/imageupdate/labels_test.go b/backend/pkg/libarcane/imageupdate/labels_test.go index d51aeae3cf..2757b36e63 100644 --- a/backend/pkg/libarcane/imageupdate/labels_test.go +++ b/backend/pkg/libarcane/imageupdate/labels_test.go @@ -259,6 +259,58 @@ func TestIsUpdateDisabled(t *testing.T) { } } +func TestIsSwarmTask(t *testing.T) { + tests := []struct { + name string + labels map[string]string + want bool + }{ + { + name: "nil labels", + labels: nil, + want: false, + }, + { + name: "empty labels", + labels: map[string]string{}, + want: false, + }, + { + name: "unrelated labels", + labels: map[string]string{"com.example.service": "api"}, + want: false, + }, + { + name: "swarm service id", + labels: map[string]string{LabelSwarmServiceID: "service-id"}, + want: true, + }, + { + name: "swarm service name", + labels: map[string]string{LabelSwarmServiceName: "web"}, + want: true, + }, + { + name: "case insensitive label key", + labels: map[string]string{"COM.DOCKER.SWARM.SERVICE.ID": "service-id"}, + want: true, + }, + { + name: "empty swarm label value", + labels: map[string]string{LabelSwarmServiceID: " "}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsSwarmTask(tt.labels); got != tt.want { + t.Errorf("IsSwarmTask() = %v, want %v", got, tt.want) + } + }) + } +} + func TestShouldDisableArcaneServerRedeploy(t *testing.T) { tests := []struct { name string diff --git a/go.work.sum b/go.work.sum index 675d348dbf..87dc8f5c0b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -604,10 +604,13 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8I github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= @@ -1151,7 +1154,6 @@ go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJ go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= From da3581b798180b1ed0bad24043b3fcfcaded8a65 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 15:38:00 -0500 Subject: [PATCH 28/40] fix: incorrect paths for activity syncing --- .../middleware/environment_middleware.go | 4 ++ .../middleware/environment_middleware_test.go | 45 +++++++++++++++++++ backend/pkg/libarcane/edge/commands.go | 14 ++++++ backend/pkg/libarcane/edge/commands_test.go | 5 +++ 4 files changed, 68 insertions(+) diff --git a/backend/internal/middleware/environment_middleware.go b/backend/internal/middleware/environment_middleware.go index e0a1b6eec4..668eedcfcd 100644 --- a/backend/internal/middleware/environment_middleware.go +++ b/backend/internal/middleware/environment_middleware.go @@ -200,6 +200,10 @@ func (m *EnvironmentMiddleware) hasResourcePath(c echo.Context, envID string) bo } func isManagementPathInternal(suffix string) bool { + if suffix == "/activities" || strings.HasPrefix(suffix, "/activities/") { + return true + } + if strings.HasPrefix(suffix, "/notifications") { return true } diff --git a/backend/internal/middleware/environment_middleware_test.go b/backend/internal/middleware/environment_middleware_test.go index 3d3f91ed2d..3dddc1ca82 100644 --- a/backend/internal/middleware/environment_middleware_test.go +++ b/backend/internal/middleware/environment_middleware_test.go @@ -144,6 +144,51 @@ func TestEnvironmentMiddleware_KeepsNotificationEndpointsLocal(t *testing.T) { assert.True(t, localHandlerHit) } +func TestEnvironmentMiddleware_KeepsActivityEndpointsLocal(t *testing.T) { + tests := []struct { + name string + method string + route string + path string + }{ + { + name: "list activities", + method: http.MethodGet, + route: "/environments/:id/activities", + path: "/api/environments/env-edge/activities?limit=50", + }, + { + name: "stream activities", + method: http.MethodGet, + route: "/environments/:id/activities/stream", + path: "/api/environments/env-edge/activities/stream?limit=50", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware := newTestEnvironmentMiddleware() + router := echo.New() + api := attachMiddleware(router, middleware) + + localHandlerHit := false + api.Add(tt.method, tt.route, func(c echo.Context) error { + localHandlerHit = true + return c.JSON(http.StatusOK, map[string]any{"success": true}) + }) + + req := httptest.NewRequest(tt.method, tt.path, nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), "\"success\":true") + assert.True(t, localHandlerHit) + }) + } +} + func TestEnvironmentMiddleware_ProxyWebSocketRejectsEdgeTargetsWithoutTunnel(t *testing.T) { middleware := newTestEnvironmentMiddleware() e := echo.New() diff --git a/backend/pkg/libarcane/edge/commands.go b/backend/pkg/libarcane/edge/commands.go index 54a83df86c..f7ac6ce688 100644 --- a/backend/pkg/libarcane/edge/commands.go +++ b/backend/pkg/libarcane/edge/commands.go @@ -10,6 +10,7 @@ type commandRoute struct { PathPattern string CommandName string Stream bool + LocalOnly bool } var commandRoutes = []commandRoute{ @@ -48,6 +49,12 @@ var commandRoutes = []commandRoute{ {Method: http.MethodPost, PathPattern: "/api/environments/{id}/image-updates/check-all", CommandName: "image_update.check_all"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/image-updates/summary", CommandName: "image_update.summary"}, + {Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities", CommandName: "activity.list"}, + {Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities/stream", LocalOnly: true}, + {Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities/{activityId}", CommandName: "activity.inspect"}, + {Method: http.MethodPost, PathPattern: "/api/environments/{id}/activities/{activityId}/cancel", CommandName: "activity.cancel"}, + {Method: http.MethodDelete, PathPattern: "/api/environments/{id}/activities/history", CommandName: "activity.history.clear"}, + {Method: http.MethodPost, PathPattern: "/api/environments/{id}/images/{imageId}/vulnerabilities/scan", CommandName: "vulnerability.scan"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/images/{imageId}/vulnerabilities", CommandName: "vulnerability.list"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/images/{imageId}/vulnerabilities/summary", CommandName: "vulnerability.summary"}, @@ -195,6 +202,10 @@ func ResolveEdgeCommandName(method, requestPath string, stream bool) (string, bo return "", false } + if route.LocalOnly { + return "", false + } + return route.CommandName, true } @@ -207,6 +218,9 @@ func AdvertisedEdgeCommands() []string { seen := make(map[string]struct{}, len(commandRoutes)) commands := make([]string, 0, len(commandRoutes)) for _, route := range commandRoutes { + if route.LocalOnly { + continue + } if _, ok := seen[route.CommandName]; ok { continue } diff --git a/backend/pkg/libarcane/edge/commands_test.go b/backend/pkg/libarcane/edge/commands_test.go index 4e26467ccc..130b36a639 100644 --- a/backend/pkg/libarcane/edge/commands_test.go +++ b/backend/pkg/libarcane/edge/commands_test.go @@ -25,6 +25,11 @@ func TestResolveEdgeCommandName(t *testing.T) { {name: "project logs stream", method: "GET", path: "/api/environments/0/ws/projects/p1/logs", stream: true, command: "project.logs.stream", shouldHit: true}, {name: "project updates", method: "GET", path: "/api/environments/0/projects/p1/updates", command: "project.updates", shouldHit: true}, {name: "project archive", method: "POST", path: "/api/environments/0/projects/p1/archive", command: "project.archive", shouldHit: true}, + {name: "activity list", method: "GET", path: "/api/environments/0/activities?limit=50", command: "activity.list", shouldHit: true}, + {name: "activity inspect", method: "GET", path: "/api/environments/0/activities/activity-1", command: "activity.inspect", shouldHit: true}, + {name: "activity cancel", method: "POST", path: "/api/environments/0/activities/activity-1/cancel", command: "activity.cancel", shouldHit: true}, + {name: "activity history clear", method: "DELETE", path: "/api/environments/0/activities/history", command: "activity.history.clear", shouldHit: true}, + {name: "activity stream remains manager local", method: "GET", path: "/api/environments/0/activities/stream?limit=50", shouldHit: false}, {name: "health", method: "HEAD", path: "/api/environments/0/system/health", command: "system.health", shouldHit: true}, {name: "unknown", method: "PATCH", path: "/api/environments/0/containers", shouldHit: false}, } From 7fb3977ef75e23ab533588ba73fd6a35fe2e1e2f Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 18:07:47 -0500 Subject: [PATCH 29/40] fix: add global csrf middleware (#2780) --- .../internal/bootstrap/router_bootstrap.go | 1 + .../internal/middleware/csrf_middleware.go | 91 +++++++++++++++++++ .../middleware/csrf_middleware_test.go | 74 +++++++++++++++ tests/spec/project.spec.ts | 20 +++- 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 backend/internal/middleware/csrf_middleware.go create mode 100644 backend/internal/middleware/csrf_middleware_test.go diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 3f2fc05fea..829a822354 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -144,6 +144,7 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *di.Servic authMiddleware := appServices.AuthMiddleware e.Use(middleware.NewCORSMiddleware(cfg).Add()) + e.Use(middleware.NewCSRFMiddleware(cfg).Add()) //nolint:contextcheck // Echo middleware uses request context from echo.Context, not the app lifecycle context. apiGroup := e.Group("/api") diff --git a/backend/internal/middleware/csrf_middleware.go b/backend/internal/middleware/csrf_middleware.go new file mode 100644 index 0000000000..6707e9eb2c --- /dev/null +++ b/backend/internal/middleware/csrf_middleware.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/models" + pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/labstack/echo/v4" +) + +// publicCrossOriginBypassPaths are endpoints intentionally reachable cross-origin +// by non-browser callers that authenticate with their OWN token carried in the +// path or request body (not the session cookie). Cross-origin protection must not +// block them, since a legitimate external caller (CI, webhook source) may attach +// an Origin header. Patterns use net/http ServeMux syntax, matched against URL path. +var publicCrossOriginBypassPaths = []string{ + "/api/webhooks/trigger/{token}", + "/api/auth/federated/token", +} + +// CSRFMiddleware rejects cross-origin state-changing requests to the cookie-backed +// API. It complements the SameSite=Lax session cookie with server-side origin +// verification (Sec-Fetch-Site, with an Origin/Host fallback) via the standard +// library's net/http.CrossOriginProtection. +// +// Header-credentialed requests (Bearer / X-API-Key / agent token) are not CSRF-able +// — a browser cannot attach those headers to a forged cross-origin request — so they +// are left untouched, as are non-browser clients that send no Origin header. +type CSRFMiddleware struct { + cfg *config.Config +} + +func NewCSRFMiddleware(cfg *config.Config) *CSRFMiddleware { + return &CSRFMiddleware{cfg: cfg} +} + +func (m *CSRFMiddleware) Add() echo.MiddlewareFunc { + // Edge Agent mode: skip. The agent only receives server-to-server requests + // through the edge tunnel from the manager — never from browsers. Mirrors CORSMiddleware. + if m.cfg != nil && m.cfg.EdgeAgent { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { return next(c) } + } + } + + cop := http.NewCrossOriginProtection() + for _, origin := range deriveAllowedOriginsInternal(m.cfg, nil) { + // AddTrustedOrigin expects scheme://host[:port]; deriveAllowedOriginsInternal + // already produces that form (shared with CORS). Skip anything malformed. + if err := cop.AddTrustedOrigin(origin); err != nil { + slog.Warn("CSRF: ignoring invalid trusted origin", "origin", origin, "error", err) + } + } + for _, pattern := range publicCrossOriginBypassPaths { + cop.AddInsecureBypassPattern(pattern) + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + req := c.Request() + if hasHeaderCredentialInternal(req) { + return next(c) + } + if err := cop.Check(req); err != nil { + slog.WarnContext(req.Context(), "CSRF: cross-origin request blocked", + "path", req.URL.Path, + "method", req.Method, + "origin", req.Header.Get("Origin"), + "sec_fetch_site", req.Header.Get("Sec-Fetch-Site"), + ) + return c.JSON(http.StatusForbidden, models.APIError{ + Code: models.APIErrorCodeForbidden, + Message: "Cross-origin request blocked", + }) + } + return next(c) + } + } +} + +// hasHeaderCredentialInternal reports whether the request carries a credential in a +// header that a browser cannot forge on a cross-origin request, meaning it is not +// susceptible to CSRF and should bypass the cross-origin check. +func hasHeaderCredentialInternal(req *http.Request) bool { + return strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ") || + req.Header.Get(pkgutils.HeaderApiKey) != "" || + req.Header.Get(pkgutils.HeaderAgentToken) != "" +} diff --git a/backend/internal/middleware/csrf_middleware_test.go b/backend/internal/middleware/csrf_middleware_test.go new file mode 100644 index 0000000000..c66fc0a70f --- /dev/null +++ b/backend/internal/middleware/csrf_middleware_test.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func newCSRFTestRouterInternal() *echo.Echo { + cfg := &config.Config{Environment: "production", AppUrl: "https://arcane.example.com"} + router := echo.New() + router.Use(NewCSRFMiddleware(cfg).Add()) + router.POST("/api/test", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + router.POST("/api/webhooks/trigger/:token", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + return router +} + +func TestCSRF_BlocksCrossSiteStateChange(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://evil.example.com") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestCSRF_AllowsSameOrigin(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + req.Header.Set("Sec-Fetch-Site", "same-origin") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +// Non-browser clients (curl, CLI, CI, the report's Python PoC) send no Origin or +// Sec-Fetch-Site header and must not be blocked. +func TestCSRF_AllowsNonBrowserClient(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +// A header credential cannot be forged cross-origin by a browser, so even a +// cross-site Origin must not block such a request. +func TestCSRF_SkipsHeaderCredentialedRequests(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("X-Api-Key", "some-key") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +// Public webhook trigger authenticates via its path token, not the session cookie, +// and may receive an Origin header from an external caller; it must be bypassed. +func TestCSRF_AllowsPublicBypassPaths(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/webhooks/trigger/abc", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://ci.example.com") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/tests/spec/project.spec.ts b/tests/spec/project.spec.ts index ca60ffa011..76e39d993d 100644 --- a/tests/spec/project.spec.ts +++ b/tests/spec/project.spec.ts @@ -562,6 +562,8 @@ test.describe('New Compose Project Page', () => { }); test('should create a new project successfully', async ({ page }) => { + test.slow(); + const projectName = `test-project-${Date.now()}`; const containerName = `test-nginx-container-${Date.now()}`; const envFile = TEST_ENV_FILE.replace(/CONTAINER_NAME=.*/m, `CONTAINER_NAME=${containerName}`); @@ -654,10 +656,24 @@ test.describe('New Compose Project Page', () => { .getByRole('button', { name: 'Up', exact: true }) .filter({ hasText: 'Up' }) .last(); + const deployResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + /\/api\/environments\/[^/]+\/projects\/[^/]+\/up$/.test(getPathname(response.url())) + ); await deployButton.click(); - await page.waitForTimeout(5000); - await page.waitForLoadState('load'); + const deployResponse = await deployResponsePromise; + expect(deployResponse.ok()).toBe(true); + expect(await deployResponse.finished()).toBeNull(); + + const projectId = createdProjectId ?? getProjectIdFromPageUrl(page.url()); + await expect + .poll(async () => (await fetchProjectDetail(page, projectId))?.status, { + message: 'Expected project to be running after deploy', + timeout: 60000 + }) + .toBe('running'); expect(projectPullRequestCount).toBe(0); await expect(page.getByText('Running', { exact: true }).first()).toBeVisible({ From 0cda74f24d0eb30d0b40f2cb88cbd4f9c8d0588d Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 18:27:56 -0500 Subject: [PATCH 30/40] chore: update test runner --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a03fa39540..e819205945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,7 +223,7 @@ jobs: e2e-tests: name: E2E Tests (${{ matrix.database.name }}) if: ${{ github.head_ref != 'l10n_crowdin' }} - runs-on: depot-ubuntu-latest + runs-on: depot-ubuntu-24.04-4 permissions: contents: read actions: write @@ -301,7 +301,7 @@ jobs: cli-e2e-tests: name: CLI E2E Tests (proxy) if: ${{ github.head_ref != 'l10n_crowdin' }} - runs-on: depot-ubuntu-latest + runs-on: depot-ubuntu-24.04-4 permissions: contents: read actions: write From 618493be99281a2ecfe1ba84c2e01fdd61b5aaaf Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 18:40:47 -0500 Subject: [PATCH 31/40] chore: refactor tests --- tests/spec/volumes.spec.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/spec/volumes.spec.ts b/tests/spec/volumes.spec.ts index 9d066e4bc8..576ac98bc7 100644 --- a/tests/spec/volumes.spec.ts +++ b/tests/spec/volumes.spec.ts @@ -72,6 +72,20 @@ async function removeVolumeViaApi(page: Page, volumeName: string) { .catch(() => undefined); } +async function gotoVolumeDetail(page: Page, volumeName: string) { + const volumePath = `/api/environments/0/volumes/${encodeURIComponent(volumeName)}`; + const detailResponse = page.waitForResponse((response) => { + const request = response.request(); + return request.method() === 'GET' && new URL(response.url()).pathname === volumePath; + }); + + await page.goto(`/volumes/${encodeURIComponent(volumeName)}`); + const response = await detailResponse; + expect(response.ok(), `Expected successful GET ${volumePath}`).toBeTruthy(); + await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); + await expect(page.getByRole('heading', { level: 1, name: volumeName })).toBeVisible(); +} + function facetIds(title: string) { const key = title.toLowerCase(); return { @@ -126,11 +140,7 @@ test.describe('Volumes Page', () => { try { await createVolumeViaApi(page, volumeName); - await page.goto(`/volumes/${encodeURIComponent(volumeName)}`); - await page.waitForLoadState('load'); - - await expect(page).toHaveURL(new RegExp(`/volumes/.+`)); - await expect(page.getByRole('heading', { level: 1, name: volumeName })).toBeVisible(); + await gotoVolumeDetail(page, volumeName); } finally { await removeVolumeViaApi(page, volumeName); } @@ -168,8 +178,7 @@ test.describe('Volumes Page', () => { const volumeName = `e2e-badge-volume-${Date.now()}`; try { await createVolumeViaApi(page, volumeName); - await page.goto(`/volumes/${encodeURIComponent(volumeName)}`); - await page.waitForLoadState('load'); + await gotoVolumeDetail(page, volumeName); await expect(page.getByText('Unused').first()).toBeVisible(); } finally { From 023af9f6d82fe8c2e6ad4f4a11deb6c87650c918 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 31 May 2026 18:51:45 -0500 Subject: [PATCH 32/40] chore: fix missing import --- backend/internal/bootstrap/router_bootstrap.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 829a822354..fcc18970b1 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "net" + "net/http" "path" "strings" From 5ab7b57aae08c848f7b30d755b08cb6ee5cb5561 Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Mon, 1 Jun 2026 11:37:08 +1000 Subject: [PATCH 33/40] chore: enforce LF for husky hooks, Justfile, and shell scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These files break under CRLF on Linux: husky hooks fail with "command not found" because bash chokes on \r in shebangs, just fails to parse Justfile, and shell scripts copied into runner containers exec strangely. The .gitattributes here is intentionally narrow — only the tools that actually break, not a repo-wide normalization that would re-touch every text file on contributors' machines with autocrlf configured differently. --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..5bcb23fcd6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Files that must be LF on disk regardless of the checkout platform, because +# they're consumed by tooling that chokes on CRLF (bash shebangs, just, +# shell scripts copied verbatim into Linux runner containers). +.husky/* text eol=lf +.husky/_/* text eol=lf +Justfile text eol=lf +*.sh text eol=lf From f7388a92a67ee1891d12259523b2a9cca60d52e4 Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Mon, 1 Jun 2026 11:37:56 +1000 Subject: [PATCH 34/40] feat(lifecycle): pre-deploy hook backend for gitops syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pre-deploy lifecycle hook capability to GitOps syncs: each sync can configure a script (committed in the repo) that runs in a throwaway container immediately before the linked project deploys. The canonical use case is decrypting/generating files compose then consumes via env_file: or .env — e.g. `sops -d secrets.enc.env > .env.runtime` — without any Arcane-specific machinery in the loop. Schema - Migration 053 adds pre_deploy_{script_path, runner_image, env, extra_mounts, timeout_sec, network_mode, last_run_at, last_run_status, last_run_output} columns to gitops_syncs. - Postgres ↔ SQLite parity; defaults preserve existing rows. Service - LifecycleService.RunPreDeploy is invoked from DeployProject before the compose project is loaded, so the hook can produce files the compose later parses. A non-zero exit or timeout aborts the deploy. - Runner container hardening: - SecurityOpt: no-new-privileges:true - CapDrop: ALL (drops the default capability set Docker grants root inside the container) - NetworkMode defaults to "none" so the script has no outbound access unless explicitly opted in via the dialog - Workspace mount is RW so artifacts persist into the project - MountForSubpath helper picks TypeBind for host-bind /app/data and TypeVolume + VolumeOptions.Subpath for named-volume backed setups — fixes both Docker-Desktop-on-WSL2 dev and named-volume production deploys - Script + extra-mount paths validated against POSIX semantics (path.IsAbs over filepath.ToSlash) so the validator behaves the same on Linux servers and Windows-based contributor machines. GitOps sync integration - Validator returns *models.ValidationError with a Field name so the API surfaces 400 + structured details for field-attribution. - redeployAfterSyncFailedError surfaces auto-sync redeploy failures on the sync row's LastSyncError without losing the synced-file list. - GitOps sync error wrappers now surface the inner error via "%v" (matched to RegistryCreationError pattern) so dialog toasts show the actual reason. Files preservation - WriteSyncedDirectory honors SyncFile.Executable (sourced from git's 100755 bit via the walker) and re-chmods on update so a hook script committed as executable arrives in the workspace runnable. Settings - LifecycleEnabled (kill switch, default off) and LifecycleMaxTimeoutSec (cap, default 300s) added to the AppSettings struct and the Settings Update DTO with envOverride so admins can disable hooks via env without DB access. --- backend/internal/common/errors.go | 4 +- backend/internal/configschema/schema_test.go | 2 + backend/internal/di/providers.go | 1 + backend/internal/di/wire.go | 1 + backend/internal/di/wire_gen.go | 6 +- backend/internal/models/event.go | 5 + backend/internal/models/gitops_sync.go | 18 + backend/internal/models/settings.go | 2 + .../services/dashboard_service_test.go | 2 +- .../internal/services/gitops_sync_service.go | 285 ++++++- .../services/gitops_sync_service_test.go | 252 +++++- .../internal/services/lifecycle_service.go | 758 ++++++++++++++++++ .../services/lifecycle_service_test.go | 418 ++++++++++ backend/internal/services/project_service.go | 54 +- .../internal/services/project_service_test.go | 134 ++-- backend/internal/services/settings_service.go | 2 + .../internal/services/updater_service_test.go | 2 +- backend/pkg/dockerutil/mount_utils.go | 107 +++ backend/pkg/dockerutil/mount_utils_test.go | 82 ++ backend/pkg/gitutil/git.go | 10 + backend/pkg/gitutil/git_test.go | 32 + backend/pkg/projects/fs_writer.go | 17 +- backend/pkg/projects/fs_writer_test.go | 53 ++ backend/pkg/projects/load.go | 1 + ...7_add_gitops_sync_pre_deploy_hook.down.sql | 9 + ...057_add_gitops_sync_pre_deploy_hook.up.sql | 20 + ...7_add_gitops_sync_pre_deploy_hook.down.sql | 9 + ...057_add_gitops_sync_pre_deploy_hook.up.sql | 20 + types/gitops/gitops.go | 139 ++++ types/settings/settings.go | 13 + 30 files changed, 2355 insertions(+), 103 deletions(-) create mode 100644 backend/internal/services/lifecycle_service.go create mode 100644 backend/internal/services/lifecycle_service_test.go create mode 100644 backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql create mode 100644 backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql create mode 100644 backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql create mode 100644 backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index fd0fce289c..1b7e4b50e6 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -1464,7 +1464,7 @@ type GitOpsSyncCreationError struct { } func (e *GitOpsSyncCreationError) Error() string { - return "Failed to create GitOps sync" + return fmt.Sprintf("Failed to create GitOps sync: %v", e.Err) } type GitOpsSyncRetrievalError struct { @@ -1480,7 +1480,7 @@ type GitOpsSyncUpdateError struct { } func (e *GitOpsSyncUpdateError) Error() string { - return "Failed to update GitOps sync" + return fmt.Sprintf("Failed to update GitOps sync: %v", e.Err) } type GitOpsSyncDeletionError struct { diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index f7a58a0edc..89d07d7c80 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -303,6 +303,8 @@ var expectedSettingOverrideKeys = []string{ "gitopsSyncInterval", "httpClientTimeout", "keyboardShortcutsEnabled", + "lifecycleEnabled", + "lifecycleMaxTimeoutSec", "maxImageUploadSize", "mobileNavigationMode", "mobileNavigationShowLabels", diff --git a/backend/internal/di/providers.go b/backend/internal/di/providers.go index 6ab8867c61..159d8d0a7a 100644 --- a/backend/internal/di/providers.go +++ b/backend/internal/di/providers.go @@ -35,6 +35,7 @@ type Services struct { Image *services.ImageService Build *services.BuildService BuildWorkspace *services.BuildWorkspaceService + Lifecycle *services.LifecycleService Volume *services.VolumeService Network *services.NetworkService Port *services.PortService diff --git a/backend/internal/di/wire.go b/backend/internal/di/wire.go index 6d03446cda..71d25a46ca 100644 --- a/backend/internal/di/wire.go +++ b/backend/internal/di/wire.go @@ -45,6 +45,7 @@ var ServiceSet = wire.NewSet( services.NewImageService, services.NewBuildService, services.NewBuildWorkspaceService, + services.NewLifecycleService, services.NewProjectService, services.NewContainerService, services.NewDashboardService, diff --git a/backend/internal/di/wire_gen.go b/backend/internal/di/wire_gen.go index dc74b265c0..fc0d8bb70f 100644 --- a/backend/internal/di/wire_gen.go +++ b/backend/internal/di/wire_gen.go @@ -43,7 +43,8 @@ func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config imageService := services.NewImageService(db, dockerClientService, containerRegistryService, imageUpdateService, vulnerabilityService, eventService) gitRepositoryService := provideGitRepositoryServiceInternal(db, cfg, eventService, settingsService) buildService := services.NewBuildService(db, settingsService, dockerClientService, containerRegistryService, gitRepositoryService, eventService) - projectService := services.NewProjectService(db, settingsService, eventService, imageService, dockerClientService, buildService, cfg) + lifecycleService := services.NewLifecycleService(db, settingsService, eventService, dockerClientService) + projectService := services.NewProjectService(db, settingsService, eventService, imageService, dockerClientService, buildService, lifecycleService, cfg) jobService := services.NewJobService(db, settingsService, cfg) settingsSearchService := services.NewSettingsSearchService() customizeSearchService := services.NewCustomizeSearchService() @@ -81,6 +82,7 @@ func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config Image: imageService, Build: buildService, BuildWorkspace: buildWorkspaceService, + Lifecycle: lifecycleService, Volume: volumeService, Network: networkService, Port: portService, @@ -168,7 +170,7 @@ func InitializeJobs(ctx context.Context, cfg *config.Config, svcs *Services) *Jo // no longer maintained by hand. wire.Struct assembles the aggregate Services. var ServiceSet = wire.NewSet( - provideResourcesFSInternal, services.NewEventService, services.NewActivityService, services.NewSettingsService, services.NewKVService, services.NewJobService, services.NewSettingsSearchService, services.NewCustomizeSearchService, services.NewApplicationImagesService, services.NewDockerClientService, services.NewRoleService, services.NewSessionService, services.NewEnvironmentService, services.NewNotificationService, services.NewVulnerabilityService, services.NewImageUpdateService, services.NewImageService, services.NewBuildService, services.NewBuildWorkspaceService, services.NewProjectService, services.NewContainerService, services.NewDashboardService, services.NewNetworkService, services.NewPortService, services.NewSwarmService, services.NewTemplateService, services.NewOidcService, services.NewSystemService, services.NewSystemUpgradeService, services.NewDiagnosticsService, services.NewGitOpsSyncService, services.NewWebhookService, provideVersionServiceInternal, + provideResourcesFSInternal, services.NewEventService, services.NewActivityService, services.NewSettingsService, services.NewKVService, services.NewJobService, services.NewSettingsSearchService, services.NewCustomizeSearchService, services.NewApplicationImagesService, services.NewDockerClientService, services.NewRoleService, services.NewSessionService, services.NewEnvironmentService, services.NewNotificationService, services.NewVulnerabilityService, services.NewImageUpdateService, services.NewImageService, services.NewBuildService, services.NewBuildWorkspaceService, services.NewLifecycleService, services.NewProjectService, services.NewContainerService, services.NewDashboardService, services.NewNetworkService, services.NewPortService, services.NewSwarmService, services.NewTemplateService, services.NewOidcService, services.NewSystemService, services.NewSystemUpgradeService, services.NewDiagnosticsService, services.NewGitOpsSyncService, services.NewWebhookService, provideVersionServiceInternal, provideGitRepositoryServiceInternal, provideVolumeServiceInternal, provideAuthServiceInternal, diff --git a/backend/internal/models/event.go b/backend/internal/models/event.go index 9d15cc7a32..335b840477 100644 --- a/backend/internal/models/event.go +++ b/backend/internal/models/event.go @@ -89,6 +89,11 @@ const ( EventTypeWebhookDelete EventType = "webhook.delete" EventTypeWebhookTrigger EventType = "webhook.trigger" + // EventTypeLifecycleExecute is emitted by LifecycleService each time a + // pre-deploy script runs. Severity is success on a clean exit and warning + // on non-zero exit or timeout. + EventTypeLifecycleExecute EventType = "lifecycle.execute" + // EventSeverityInfo and the constants below enumerate event severities. EventSeverityInfo EventSeverity = "info" EventSeverityWarning EventSeverity = "warning" diff --git a/backend/internal/models/gitops_sync.go b/backend/internal/models/gitops_sync.go index b83bffbb35..cb9dad3fa1 100644 --- a/backend/internal/models/gitops_sync.go +++ b/backend/internal/models/gitops_sync.go @@ -29,6 +29,24 @@ type GitOpsSync struct { LastSyncStatus *string `json:"lastSyncStatus,omitempty" search:"status,success,failed,pending,error"` LastSyncError *string `json:"lastSyncError,omitempty"` LastSyncCommit *string `json:"lastSyncCommit,omitempty" search:"commit,hash,sha,revision"` + + // Pre-deploy lifecycle hook (configuration) + // When PreDeployScriptPath is set, the named script is executed in a + // throwaway container before each deploy of the linked project. The script, + // runner image, and execution context together act as repo-trusted code — + // any push to the repo that changes the script will run unreviewed on the + // next deploy. See docs for details. + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty" gorm:"column:pre_deploy_script_path" search:"lifecycle,hook,pre-deploy,script,path"` + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty" gorm:"column:pre_deploy_runner_image"` + PreDeployEnv *string `json:"preDeployEnv,omitempty" gorm:"column:pre_deploy_env"` // KEY=VALUE lines, one per line; same format as .env files + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty" gorm:"column:pre_deploy_extra_mounts"` // docker -v style "src:tgt[:ro|:rw]" entries, one per line + PreDeployTimeoutSec int `json:"preDeployTimeoutSec" gorm:"column:pre_deploy_timeout_sec;default:60"` + PreDeployNetworkMode string `json:"preDeployNetworkMode" gorm:"column:pre_deploy_network_mode;default:'none'"` // Docker network mode passed to the runner container. Default "none" denies network access; set to "bridge", "host", or a named network when the script needs it. + + // Pre-deploy lifecycle hook (last-run state) + PreDeployLastRunAt *time.Time `json:"preDeployLastRunAt,omitempty" gorm:"column:pre_deploy_last_run_at" sortable:"true"` + PreDeployLastRunStatus *string `json:"preDeployLastRunStatus,omitempty" gorm:"column:pre_deploy_last_run_status" sortable:"true"` // "success" | "failed" | "timeout" + PreDeployLastRunOutput *string `json:"preDeployLastRunOutput,omitempty" gorm:"column:pre_deploy_last_run_output"` // truncated stdout+stderr } func (GitOpsSync) TableName() string { diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 8f0ed7d765..391284c974 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -138,6 +138,8 @@ type Settings struct { TrivyConcurrentScanContainers SettingVariable `key:"trivyConcurrentScanContainers,envOverride" meta:"label=Trivy Concurrent Scan Containers;type=number;keywords=trivy,concurrent,scan,containers,parallel,workers,limit,security;category=security;description=Maximum number of concurrent Trivy scan containers for manual and scheduled scans. Minimum 1"` TrivyConfig SettingVariable `key:"trivyConfig" meta:"label=Trivy Config (YAML);type=textarea;keywords=trivy,config,yaml,configuration,scanner,settings;category=security;description=Trivy configuration file content in YAML format"` TrivyIgnore SettingVariable `key:"trivyIgnore" meta:"label=.trivyignore;type=textarea;keywords=trivy,ignore,ignorefile,vulnerabilities,exceptions,exclusions;category=security;description=Trivy ignore file content - one vulnerability ID per line"` + LifecycleEnabled SettingVariable `key:"lifecycleEnabled,envOverride" meta:"label=Enable Lifecycle Hooks;type=boolean;keywords=lifecycle,hook,hooks,pre-deploy,script,gitops,sops,secrets;category=security;description=Allow GitOps syncs to configure pre-deploy lifecycle scripts. Disabled by default because scripts are repo-trusted code that runs on every deploy"` + LifecycleMaxTimeoutSec SettingVariable `key:"lifecycleMaxTimeoutSec,envOverride" meta:"label=Lifecycle Hook Max Timeout (seconds);type=number;keywords=lifecycle,hook,timeout,seconds,limit,maximum,script,pre-deploy;category=security;description=Maximum allowed timeout for lifecycle scripts in seconds (default: 300)"` OidcEnabled SettingVariable `key:"oidcEnabled,public,envOverride" meta:"label=OIDC Authentication;type=boolean;keywords=oidc,openid,connect,sso,oauth,external,provider,federation;category=authentication;description=Enable OpenID Connect (OIDC) authentication"` OidcClientId SettingVariable `key:"oidcClientId,authrequired,envOverride" meta:"label=OIDC Client ID;type=text;keywords=oidc,client,id,oauth,openid;category=authentication;description=OIDC provider client ID"` OidcClientSecret SettingVariable `key:"oidcClientSecret,sensitive,envOverride" meta:"label=OIDC Client Secret;type=password;keywords=oidc,client,secret,oauth,openid;category=authentication;description=OIDC provider client secret"` diff --git a/backend/internal/services/dashboard_service_test.go b/backend/internal/services/dashboard_service_test.go index 47563991d3..6a048bbaee 100644 --- a/backend/internal/services/dashboard_service_test.go +++ b/backend/internal/services/dashboard_service_test.go @@ -155,7 +155,7 @@ func TestDashboardService_GetSnapshot_ReturnsDashboardSnapshot(t *testing.T) { Path: projectPath, Status: models.ProjectStatusStopped, }).Error) - projectSvc := NewProjectService(db, settingsSvc, nil, &ImageService{db: db}, nil, nil, config.Load()) + projectSvc := NewProjectService(db, settingsSvc, nil, &ImageService{db: db}, nil, nil, nil, config.Load()) svc := NewDashboardService(db, dockerSvc, nil, projectSvc, nil, settingsSvc, nil, nil, nil) snapshot, err := svc.GetSnapshot(context.Background(), DashboardActionItemsOptions{}) diff --git a/backend/internal/services/gitops_sync_service.go b/backend/internal/services/gitops_sync_service.go index 2a715176a7..fb6a6b6e34 100644 --- a/backend/internal/services/gitops_sync_service.go +++ b/backend/internal/services/gitops_sync_service.go @@ -8,6 +8,7 @@ import ( "log/slog" "maps" "os" + "path" "path/filepath" "strings" "time" @@ -44,6 +45,23 @@ const ( defaultMaxSyncBinarySize = defaultMaxSyncBinarySizeMB * 1024 * 1024 ) +// redeployAfterSyncFailedError is returned by the internal sync flow when +// the file sync succeeded but the auto-redeploy that follows it failed +// (e.g. a pre-deploy lifecycle hook returned non-zero). Callers surface +// this on the sync row's LastSyncError so the failure is visible from the +// gitops UI rather than only in the logs. +type redeployAfterSyncFailedError struct { + cause error +} + +func (e redeployAfterSyncFailedError) Error() string { + return "redeploy failed: " + e.cause.Error() +} + +func (e redeployAfterSyncFailedError) Unwrap() error { + return e.cause +} + type scheduledGitOpsSync struct { ID string EnvironmentID string @@ -84,6 +102,196 @@ func validateSyncLimits(maxFiles *int, maxTotalSize, maxBinarySize *int64) error return nil } +// lifecycleConfigInputInternal is the slice of CreateSyncRequest / +// UpdateSyncRequest fields relevant to the pre-deploy lifecycle hook. Both +// request types collapse into the same shape so a single validator handles +// both flows. +type lifecycleConfigInputInternal struct { + scriptPath *string + runnerImage *string + env *string + extraMounts *string + timeoutSec *int + networkMode *string + syncDirectory *bool +} + +func (s *GitOpsSyncService) validateLifecycleConfigInternal(ctx context.Context, current *models.GitOpsSync, in lifecycleConfigInputInternal) error { + lifecycleFieldSet := in.scriptPath != nil || in.runnerImage != nil || in.env != nil || in.extraMounts != nil || in.timeoutSec != nil || in.networkMode != nil + syncDirectoryChanging := in.syncDirectory != nil + effectiveScriptPath := resolveLifecycleEffectiveStringInternal(currentString(current, func(c *models.GitOpsSync) *string { return c.PreDeployScriptPath }), in.scriptPath) + + // If nothing lifecycle-related is being touched and the resulting state + // has no script configured, there's nothing to validate. + if !lifecycleFieldSet && (!syncDirectoryChanging || effectiveScriptPath == "") { + return nil + } + + // Kill switch only blocks updates that actively touch lifecycle fields; + // toggling unrelated fields on an existing config shouldn't be rejected + // just because the global setting was turned off afterwards. + if lifecycleFieldSet && !s.settingsService.GetBoolSetting(ctx, "lifecycleEnabled", false) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy lifecycle hooks are disabled. An admin must enable lifecycleEnabled in settings before they can be configured."} + } + + if effectiveScriptPath != "" { + if len(effectiveScriptPath) > 256 { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be 256 characters or fewer."} + } + // scriptPath is a POSIX repo path, not a host path; use path.IsAbs so the + // check behaves the same on Windows-based contributor machines. + if path.IsAbs(filepath.ToSlash(effectiveScriptPath)) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be relative to the project directory."} + } + cleaned := filepath.ToSlash(filepath.Clean(effectiveScriptPath)) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must not escape the project directory."} + } + + effectiveRunnerImage := resolveLifecycleEffectiveStringInternal(currentString(current, func(c *models.GitOpsSync) *string { return c.PreDeployRunnerImage }), in.runnerImage) + if effectiveRunnerImage == "" { + return &models.ValidationError{Field: "preDeployRunnerImage", Message: "Runner image is required when a script path is set."} + } + + if !resolveEffectiveSyncDirectoryInternal(current, in.syncDirectory) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy script requires \"Sync entire directory\" so the script is included in the synced files."} + } + } + + if in.timeoutSec != nil { + if *in.timeoutSec < 1 { + return &models.ValidationError{Field: "preDeployTimeoutSec", Message: "Timeout must be at least 1 second."} + } + maxTimeoutSec := s.settingsService.GetIntSetting(ctx, "lifecycleMaxTimeoutSec", lifecycleDefaultMaxTimeoutSec) + if maxTimeoutSec > 0 && *in.timeoutSec > maxTimeoutSec { + return &models.ValidationError{Field: "preDeployTimeoutSec", Message: fmt.Sprintf("Timeout %ds exceeds the lifecycleMaxTimeoutSec setting (%ds).", *in.timeoutSec, maxTimeoutSec)} + } + } + + if _, err := parseLifecycleEnvTextInternal(in.env); err != nil { + return &models.ValidationError{Field: "preDeployEnv", Message: err.Error()} + } + if _, err := parseLifecycleExtraMountsTextInternal(in.extraMounts); err != nil { + return &models.ValidationError{Field: "preDeployExtraMounts", Message: err.Error()} + } + + return nil +} + +// resolveLifecycleEffectiveStringInternal computes the value a string field +// will take after an update: a nil update leaves the existing value, a +// non-nil update (including empty string, which clears) overrides it. Used +// to validate the post-update state of a sync record. +func resolveLifecycleEffectiveStringInternal(existing string, update *string) string { + if update != nil { + return strings.TrimSpace(*update) + } + return strings.TrimSpace(existing) +} + +func currentString(sync *models.GitOpsSync, accessor func(*models.GitOpsSync) *string) string { + if sync == nil { + return "" + } + if p := accessor(sync); p != nil { + return *p + } + return "" +} + +// resolveEffectiveSyncDirectoryInternal mirrors the string resolver but for +// the SyncDirectory bool: a nil update keeps the existing value, a non-nil +// update overrides it. On create (current=nil) and no update, defaults to +// false to match the model's create-time default. +func resolveEffectiveSyncDirectoryInternal(current *models.GitOpsSync, update *bool) bool { + if update != nil { + return *update + } + if current != nil { + return current.SyncDirectory + } + return false +} + +// applyLifecycleFieldsToSyncInternal copies lifecycle config from a Create +// request into a new GitOpsSync row before insert. Strings are trimmed; +// empty strings remain unset (nil pointer) except for fields that have a +// non-null default at the DB level. +func applyLifecycleFieldsToSyncInternal(sync *models.GitOpsSync, in lifecycleConfigInputInternal) { + sync.PreDeployScriptPath = nullableTrimmedStringInternal(in.scriptPath) + sync.PreDeployRunnerImage = nullableTrimmedStringInternal(in.runnerImage) + sync.PreDeployEnv = nullableTrimmedStringInternal(in.env) + sync.PreDeployExtraMounts = nullableTrimmedStringInternal(in.extraMounts) + if in.timeoutSec != nil { + sync.PreDeployTimeoutSec = *in.timeoutSec + } + if mode := normalizeLifecycleNetworkModeInternal(in.networkMode); mode != "" { + sync.PreDeployNetworkMode = mode + } +} + +// addLifecycleUpdatesInternal appends lifecycle field updates to the GORM +// updates map. nil pointers leave the field unchanged; non-nil empty strings +// clear nullable fields to NULL and reset fixed-default fields to their +// default value. +func addLifecycleUpdatesInternal(updates map[string]any, in lifecycleConfigInputInternal) { + if in.scriptPath != nil { + updates["pre_deploy_script_path"] = nullableUpdateStringValueInternal(in.scriptPath) + } + if in.runnerImage != nil { + updates["pre_deploy_runner_image"] = nullableUpdateStringValueInternal(in.runnerImage) + } + if in.env != nil { + updates["pre_deploy_env"] = nullableUpdateStringValueInternal(in.env) + } + if in.extraMounts != nil { + updates["pre_deploy_extra_mounts"] = nullableUpdateStringValueInternal(in.extraMounts) + } + if in.timeoutSec != nil { + updates["pre_deploy_timeout_sec"] = *in.timeoutSec + } + if in.networkMode != nil { + updates["pre_deploy_network_mode"] = normalizeLifecycleNetworkModeInternal(in.networkMode) + } +} + +// normalizeLifecycleNetworkModeInternal returns the canonical network mode +// string for a user-supplied value. Empty / whitespace / unset all map to +// "none" so the secure-by-default behaviour is restored when the user clears +// the field. +func normalizeLifecycleNetworkModeInternal(p *string) string { + if p == nil { + return "none" + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return "none" + } + return trimmed +} + +func nullableTrimmedStringInternal(p *string) *string { + if p == nil { + return nil + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return nil + } + return &trimmed +} + +func nullableUpdateStringValueInternal(p *string) any { + if p == nil { + return nil + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return nil + } + return trimmed +} + func normalizeSyncLimitSetting(value, defaultValue int) int { if value < 0 { return defaultValue @@ -280,7 +488,23 @@ func (s *GitOpsSyncService) CreateSync(ctx context.Context, environmentID string sync.MaxSyncBinarySize = *req.MaxSyncBinarySize } - if err := s.db.WithContext(ctx).Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { + lifecycleCfg := lifecycleConfigInputInternal{ + scriptPath: req.PreDeployScriptPath, + runnerImage: req.PreDeployRunnerImage, + env: req.PreDeployEnv, + extraMounts: req.PreDeployExtraMounts, + timeoutSec: req.PreDeployTimeoutSec, + networkMode: req.PreDeployNetworkMode, + syncDirectory: req.SyncDirectory, + } + if err := s.validateLifecycleConfigInternal(ctx, nil, lifecycleCfg); err != nil { + return nil, err + } + applyLifecycleFieldsToSyncInternal(&sync, lifecycleCfg) + + // Select("*") forces explicit zero values (e.g. "0 = unlimited" sync limits and + // unset pre-deploy fields) to persist instead of GORM substituting column defaults. + if err := s.db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { //nolint:unqueryvet // intentional Select("*"); see comment above slog.ErrorContext(ctx, "Failed to create GitOps sync in database", "name", req.Name, "repositoryID", req.RepositoryID, "environmentID", environmentID, "error", err) return nil, fmt.Errorf("failed to create sync: %w", err) } @@ -361,6 +585,20 @@ func (s *GitOpsSyncService) UpdateSync(ctx context.Context, environmentID, id st updates["max_sync_binary_size"] = *req.MaxSyncBinarySize } + lifecycleCfg := lifecycleConfigInputInternal{ + scriptPath: req.PreDeployScriptPath, + runnerImage: req.PreDeployRunnerImage, + env: req.PreDeployEnv, + extraMounts: req.PreDeployExtraMounts, + timeoutSec: req.PreDeployTimeoutSec, + networkMode: req.PreDeployNetworkMode, + syncDirectory: req.SyncDirectory, + } + if err := s.validateLifecycleConfigInternal(ctx, sync, lifecycleCfg); err != nil { + return nil, err + } + addLifecycleUpdatesInternal(updates, lifecycleCfg) + if len(updates) > 0 { if err := s.db.WithContext(ctx).Model(sync).Updates(updates).Error; err != nil { return nil, fmt.Errorf("failed to update sync: %w", err) @@ -532,7 +770,13 @@ func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *mode } if contentsChanged { - s.redeployIfRunningAfterSync(ctx, project, actor, "directory") + if redeployErr := s.redeployIfRunningAfterSync(ctx, project, actor, "directory"); redeployErr != nil { + if typed, ok := errors.AsType[redeployAfterSyncFailedError](redeployErr); ok { + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, typed, actor, result) + return result, redeployErr + } + return result, redeployErr + } } s.updateSyncStatusWithFiles(ctx, id, "success", "", source.commitHash, syncedFiles) @@ -550,6 +794,11 @@ func (s *GitOpsSyncService) performSingleFileSyncInternal(ctx context.Context, s project, err := s.getOrCreateProjectInternal(ctx, sync, id, source.composeContent, source.envContent, result, actor) if err != nil { + if redeployErr, ok := errors.AsType[redeployAfterSyncFailedError](err); ok { + syncedFiles := []string{filepath.Base(sync.ComposePath)} + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, redeployErr, actor, result) + return result, err + } return result, err } @@ -634,20 +883,24 @@ func (s *GitOpsSyncService) performSwarmStackSyncInternal(ctx context.Context, s } // redeployIfRunningAfterSync redeploys a project only when it is already -// running and the latest sync actually changed managed content. -func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) { +// running and the latest sync actually changed managed content. Returns a +// redeployAfterSyncFailedError when the redeploy itself fails; callers +// surface that on the sync row's LastSyncError. +func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) error { details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails()) if err != nil { - return + return nil //nolint:nilerr // best-effort: skip post-sync redeploy when project state can't be determined } if details.Status != string(models.ProjectStatusRunning) && details.Status != string(models.ProjectStatusPartiallyRunning) { - return + return nil } slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "syncMode", syncMode, "projectName", project.Name, "projectId", project.ID) if err := s.projectService.RedeployProject(ctx, project.ID, actor); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "syncMode", syncMode, "error", err, "projectId", project.ID) + return redeployAfterSyncFailedError{cause: err} } + return nil } // logSyncSuccess records the Git sync completion event once the filesystem and @@ -885,6 +1138,20 @@ func (s *GitOpsSyncService) failSync(ctx context.Context, id string, result *git return fmt.Errorf("%s", errMsg) } +// markSyncRedeployFailedInternal records a sync where the file sync wrote +// cleanly but the auto-redeploy that follows failed (typically a pre-deploy +// lifecycle hook returning non-zero). The synced-files list and commit hash +// are preserved on the row so operators can see what reached disk; the +// error message surfaces the redeploy failure on LastSyncError. +func (s *GitOpsSyncService) markSyncRedeployFailedInternal(ctx context.Context, sync *models.GitOpsSync, id, commitHash string, syncedFiles []string, redeployErr redeployAfterSyncFailedError, actor models.User, result *gitops.SyncResult) { + errMsg := redeployErr.Error() + result.Success = false + result.Message = "Sync wrote files but redeploy failed" + result.Error = new(errMsg) + s.updateSyncStatusWithFiles(ctx, id, "failed", errMsg, commitHash, syncedFiles) + s.logSyncError(ctx, sync, actor, errMsg) +} + func (s *GitOpsSyncService) createProjectForSyncInternal(ctx context.Context, sync *models.GitOpsSync, id string, composeContent string, envContent *string, result *gitops.SyncResult, actor models.User) (*models.Project, error) { project, err := s.projectService.CreateProject(ctx, sync.ProjectName, composeContent, envContent, actor) if err != nil { @@ -952,13 +1219,16 @@ func (s *GitOpsSyncService) updateProjectForSyncInternal(ctx context.Context, sy newCompose, newEnv, _ := s.projectService.GetProjectContent(ctx, project.ID) contentChanged := oldCompose != newCompose || envContentChangedInternal(oldEnv, newEnv) - // If content changed and project is running, redeploy + // If content changed and project is running, redeploy. A redeploy failure + // is bubbled up as a redeployAfterSyncFailedError so the parent flow can + // reflect it on the sync row's LastSyncError. if contentChanged { details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails()) if err == nil && (details.Status == string(models.ProjectStatusRunning) || details.Status == string(models.ProjectStatusPartiallyRunning)) { slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "projectName", project.Name, "projectId", project.ID) if err := s.projectService.RedeployProject(ctx, project.ID, actor); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "error", err, "projectId", project.ID) + return redeployAfterSyncFailedError{cause: err} } } } @@ -1030,6 +1300,7 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync syncFiles[i] = projects.SyncFile{ RelativePath: f.RelativePath, Content: f.Content, + Executable: f.Executable, } if f.RelativePath == composeFileName { composeFound = true diff --git a/backend/internal/services/gitops_sync_service_test.go b/backend/internal/services/gitops_sync_service_test.go index df65672001..c25ad20822 100644 --- a/backend/internal/services/gitops_sync_service_test.go +++ b/backend/internal/services/gitops_sync_service_test.go @@ -13,6 +13,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" git "github.com/getarcaneapp/arcane/backend/pkg/gitutil" "github.com/getarcaneapp/arcane/backend/pkg/projects" + "github.com/getarcaneapp/arcane/types/gitops" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -31,7 +32,7 @@ func setupGitOpsSyncDirectoryTestService(t *testing.T) (*GitOpsSyncService, *dat projectsDir := t.TempDir() require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) - projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) return NewGitOpsSyncService(db, nil, projectService, nil, nil, settingsService), db, projectsDir } @@ -665,3 +666,252 @@ func TestGitOpsSyncService_GetEffectiveSyncLimits(t *testing.T) { require.Equal(t, int64(0), maxBinarySize) }) } + +// setupLifecycleValidationService builds a GitOpsSyncService with lifecycle +// hooks enabled in settings so the validator's gate doesn't short-circuit +// the rule checks under test. +func setupLifecycleValidationService(t *testing.T) (*GitOpsSyncService, context.Context) { + t.Helper() + ctx := context.Background() + svc, _, _ := setupGitOpsSyncDirectoryTestService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleEnabled", "true")) + return svc, ctx +} + +func strPtr(s string) *string { return &s } +func intPtr(i int) *int { return &i } +func boolPtr(b bool) *bool { return &b } + +func TestValidateLifecycleConfig_AllNilNoError(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{})) +} + +func TestValidateLifecycleConfig_RejectsWhenGloballyDisabled(t *testing.T) { + svc, _, _ := setupGitOpsSyncDirectoryTestService(t) + ctx := context.Background() + // lifecycleEnabled defaults to false; do not enable. + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "disabled") +} + +func TestValidateLifecycleConfig_RejectsAbsoluteScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("/etc/passwd"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "relative") +} + +func TestValidateLifecycleConfig_RejectsTraversalScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("../outside.sh"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "escape") +} + +func TestValidateLifecycleConfig_RejectsOverlongScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + long := make([]byte, 257) + for i := range long { + long[i] = 'a' + } + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr(string(long)), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "256") +} + +func TestValidateLifecycleConfig_RejectsScriptWithoutRunnerImageOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "Runner image is required") +} + +func TestValidateLifecycleConfig_AcceptsScriptWithExistingRunnerImageOnUpdate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + existing.PreDeployRunnerImage = strPtr("alpine:latest") + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + })) +} + +func TestValidateLifecycleConfig_RejectsTimeoutZeroOrNegative(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + for _, v := range []int{0, -1, -3600} { + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + timeoutSec: intPtr(v), + }) + require.Errorf(t, err, "expected error for timeoutSec=%d", v) + require.Contains(t, err.Error(), "at least 1") + } +} + +func TestValidateLifecycleConfig_RejectsTimeoutAboveSettingCap(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleMaxTimeoutSec", "120")) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + timeoutSec: intPtr(300), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds") +} + +func TestValidateLifecycleConfig_RejectsInvalidEnvKey(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + env: strPtr("FOO-BAR=baz"), + }) + require.Error(t, err) +} + +func TestValidateLifecycleConfig_AcceptsValidEnv(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + env: strPtr("FOO=bar\nBAZ_2=qux"), + })) +} + +func TestValidateLifecycleConfig_RejectsRelativeMountSource(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + extraMounts: strPtr("relative/path:/in/container"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "absolute") +} + +func TestValidateLifecycleConfig_RejectsRelativeMountTarget(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + extraMounts: strPtr("/host/path:relative/target"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "absolute") +} + +func TestValidateLifecycleConfig_AllowsClearingScriptWithoutImage(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{} + existing.PreDeployScriptPath = strPtr("scripts/old.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + // User clears the script (empty string in update); image clear is implied not required. + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr(""), + })) +} + +func TestValidateLifecycleConfig_RejectsScriptWithoutSyncDirectoryOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(false), + }) + require.Error(t, err) + validationErr, ok := errors.AsType[*models.ValidationError](err) + require.True(t, ok, "expected *models.ValidationError, got %T", err) + require.Equal(t, "preDeployScriptPath", validationErr.Field) +} + +func TestValidateLifecycleConfig_AcceptsScriptWithSyncDirectoryOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(true), + })) +} + +func TestValidateLifecycleConfig_AcceptsScriptWhenExistingSyncHasSyncDirectory(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + })) +} + +func TestValidateLifecycleConfig_RejectsSyncDirectoryToggleOffWhileScriptStillSet(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + existing.PreDeployScriptPath = strPtr("scripts/deploy.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + // Admin toggles syncDirectory off without clearing the script — should be rejected. + err := svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + syncDirectory: boolPtr(false), + }) + require.Error(t, err) + validationErr, ok := errors.AsType[*models.ValidationError](err) + require.True(t, ok, "expected *models.ValidationError, got %T", err) + require.Equal(t, "preDeployScriptPath", validationErr.Field) +} + +func TestRedeployAfterSyncFailedError_FormatAndUnwrap(t *testing.T) { + cause := errors.New("pre-deploy hook bombed") + err := redeployAfterSyncFailedError{cause: cause} + + require.Equal(t, "redeploy failed: pre-deploy hook bombed", err.Error()) + require.True(t, errors.Is(err, cause), "Unwrap should expose the cause for errors.Is") + + typed, ok := errors.AsType[redeployAfterSyncFailedError](err) + require.True(t, ok) + require.Equal(t, cause, typed.cause) +} + +func TestMarkSyncRedeployFailedInternal_PersistsErrorOnSyncRow(t *testing.T) { + ctx := context.Background() + svc, db, _ := setupGitOpsSyncDirectoryTestService(t) + // Event logging requires a real EventService; the shared setup leaves it + // nil since most tests don't exercise the event path. + require.NoError(t, db.AutoMigrate(&models.Event{})) + svc.eventService = NewEventService(db, config.Load(), nil) + + sync := &models.GitOpsSync{ + BaseModel: models.BaseModel{ID: "sync-1"}, + Name: "redeploy-fail", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "compose.yml", + TargetType: "project", + } + require.NoError(t, db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(sync).Error) + + result := &gitops.SyncResult{Success: true} + syncedFiles := []string{"compose.yml", "scripts/pre-deploy.sh"} + hookErr := redeployAfterSyncFailedError{cause: errors.New("pre-deploy hook failed: exit 1")} + + svc.markSyncRedeployFailedInternal(ctx, sync, sync.ID, "abc123", syncedFiles, hookErr, models.User{BaseModel: models.BaseModel{ID: "user"}, Username: "tester"}, result) + + require.False(t, result.Success) + require.NotNil(t, result.Error) + require.Contains(t, *result.Error, "pre-deploy hook failed") + + var stored models.GitOpsSync + require.NoError(t, db.WithContext(ctx).First(&stored, "id = ?", sync.ID).Error) + require.NotNil(t, stored.LastSyncStatus) + require.Equal(t, "failed", *stored.LastSyncStatus) + require.NotNil(t, stored.LastSyncError) + require.Contains(t, *stored.LastSyncError, "pre-deploy hook failed") + // The synced-files list should still be populated so operators can see + // what reached disk before the redeploy died. + require.NotNil(t, stored.SyncedFiles) + require.Contains(t, *stored.SyncedFiles, "compose.yml") + require.Contains(t, *stored.SyncedFiles, "scripts/pre-deploy.sh") +} diff --git a/backend/internal/services/lifecycle_service.go b/backend/internal/services/lifecycle_service.go new file mode 100644 index 0000000000..7a2ba4ad3f --- /dev/null +++ b/backend/internal/services/lifecycle_service.go @@ -0,0 +1,758 @@ +package services + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + cerrdefs "github.com/containerd/errdefs" + "github.com/moby/moby/api/pkg/stdcopy" + containertypes "github.com/moby/moby/api/types/container" + mounttypes "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" + "github.com/getarcaneapp/arcane/backend/pkg/projects" +) + +// Lifecycle hook configuration limits and conventions. +const ( + // lifecycleWorkspaceMount is the in-container path the project dir is + // bind-mounted to. Scripts run with this as their working dir. + lifecycleWorkspaceMount = "/workspace" + // lifecycleMaxOutputBytes caps the stdout/stderr we retain on the + // GitOpsSync row. The full stream is still consumed; the tail is dropped. + lifecycleMaxOutputBytes = 16 * 1024 + // lifecycleDefaultTimeoutSec is used when a sync has no per-sync timeout + // configured. + lifecycleDefaultTimeoutSec = 60 + // lifecycleDefaultMaxTimeoutSec mirrors the settings default so callers + // always have a sane upper bound even if settings can't be loaded. + lifecycleDefaultMaxTimeoutSec = 300 + // lifecycleStreamDrainTimeout bounds how long we wait for the log copy + // goroutine to drain after the container exits, before force-closing. + // Mirrors the value used by VulnerabilityService for the same purpose. + lifecycleStreamDrainTimeout = 30 * time.Second +) + +// Last-run status values written to GitOpsSync.PreDeployLastRunStatus. +const ( + lifecycleStatusSuccess = "success" + lifecycleStatusFailed = "failed" + lifecycleStatusTimeout = "timeout" +) + +// lifecycleEnvKeyRegex enforces POSIX-style identifier syntax for env keys +// both in admin-configured Env and in scripts' KEY=VALUE stdout capture. +var lifecycleEnvKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// lifecycleExtraMount is one admin-configured bind mount for the lifecycle +// runner container. Stored on the GitOpsSync row as docker-CLI-style +// "src:tgt[:ro|:rw]" entries, one per line; never sourced from repo data. +type lifecycleExtraMount struct { + Source string + Target string + Readonly bool +} + +// LifecycleService runs pre-deploy lifecycle hooks declared on a project's +// GitOps sync. A hook is a script in the synced repo executed in a throwaway +// container immediately before the project is deployed, with optional capture +// of stdout as environment variables merged into the compose env. +// +// Trust model: the script is repo-trusted code, equivalent to compose.yaml in +// the same repo. Anyone who can push to that repo can change what the script +// does on the next deploy. The trust event is configuring a script path on +// the GitOps sync, not each individual deploy. +type LifecycleService struct { + db *database.DB + settingsService *SettingsService + eventService *EventService + dockerService *DockerClientService +} + +// NewLifecycleService constructs a LifecycleService wired against shared +// infrastructure. The Docker client is obtained lazily on each hook run via +// dockerService.GetClient so reconnects are transparent. +func NewLifecycleService(db *database.DB, settingsService *SettingsService, eventService *EventService, dockerService *DockerClientService) *LifecycleService { + return &LifecycleService{ + db: db, + settingsService: settingsService, + eventService: eventService, + dockerService: dockerService, + } +} + +// RunPreDeploy executes the pre-deploy lifecycle hook for a project, if one +// is configured on its GitOps sync. +// +// Callers should invoke this unconditionally before deploying — when no hook +// is configured, when lifecycle hooks are disabled globally, or when the +// project is not GitOps-managed, this is a no-op and returns nil. +// +// A non-zero exit code, a script timeout, or any infrastructure failure +// returns an error that aborts the deploy. The last-run state on the +// GitOpsSync row is updated on every invocation that reaches the run step, +// regardless of outcome. +func (s *LifecycleService) RunPreDeploy(ctx context.Context, project *models.Project) error { + if project == nil || project.GitOpsManagedBy == nil || *project.GitOpsManagedBy == "" { + return nil + } + if !s.settingsService.GetBoolSetting(ctx, "lifecycleEnabled", false) { + return nil + } + + sync, err := s.loadGitOpsSyncForProjectInternal(ctx, project.ID) + if err != nil { + return fmt.Errorf("failed to load gitops sync for lifecycle hook: %w", err) + } + if sync == nil || sync.PreDeployScriptPath == nil || strings.TrimSpace(*sync.PreDeployScriptPath) == "" { + return nil + } + + return s.executePreDeployInternal(ctx, project, sync) +} + +func (s *LifecycleService) executePreDeployInternal(ctx context.Context, project *models.Project, sync *models.GitOpsSync) error { + runnerImage := strings.TrimSpace(stringValueInternal(sync.PreDeployRunnerImage)) + if runnerImage == "" { + return fmt.Errorf("pre-deploy script %q is configured but no runner image is set on the GitOps sync", *sync.PreDeployScriptPath) + } + + scriptPath := strings.TrimSpace(*sync.PreDeployScriptPath) + if err := validateScriptPathInternal(project.Path, scriptPath); err != nil { + return fmt.Errorf("invalid pre-deploy script path: %w", err) + } + + hookEnv, err := parseLifecycleEnvTextInternal(sync.PreDeployEnv) + if err != nil { + return fmt.Errorf("invalid lifecycle env config: %w", err) + } + extraMounts, err := parseLifecycleExtraMountsTextInternal(sync.PreDeployExtraMounts) + if err != nil { + return fmt.Errorf("invalid lifecycle extra mounts config: %w", err) + } + + timeout := s.resolveTimeoutInternal(ctx, sync.PreDeployTimeoutSec) + + slog.InfoContext(ctx, "running pre-deploy lifecycle hook", + "projectID", project.ID, + "syncID", sync.ID, + "scriptPath", scriptPath, + "runnerImage", runnerImage, + "timeoutSec", int(timeout/time.Second), + ) + + start := time.Now() + stdoutContent, stderrContent, exitCode, runErr := s.runScriptInContainerInternal( + ctx, + runnerImage, + project.Path, + scriptPath, + hookEnv, + extraMounts, + sync.PreDeployNetworkMode, + timeout, + ) + durationMs := time.Since(start).Milliseconds() + + status := lifecycleStatusForResultInternal(exitCode, runErr) + persistedOutput := truncateLifecycleOutputInternal(combineLifecycleOutputInternal(stdoutContent, stderrContent), lifecycleMaxOutputBytes) + s.persistLastRunInternal(ctx, sync.ID, status, persistedOutput, start) + s.emitLifecycleEventInternal(ctx, project, sync, status, exitCode, durationMs, runErr) + + if runErr != nil { + return runErr + } + if exitCode != 0 { + return fmt.Errorf("pre-deploy script exited with status %d", exitCode) + } + return nil +} + +// runScriptInContainerInternal performs the docker run + log capture + wait. +// On the happy path it returns stdout, stderr, the script's exit code and a +// nil error. context.DeadlineExceeded from the per-run timeout is wrapped and +// returned as the error so callers can distinguish a timeout from a non-zero +// exit. +func (s *LifecycleService) runScriptInContainerInternal( + ctx context.Context, + runnerImage string, + projectPath string, + scriptPath string, + hookEnv map[string]string, + extraMounts []lifecycleExtraMount, + networkMode string, + timeout time.Duration, +) (stdoutContent string, stderrContent string, exitCode int64, err error) { + dockerClient, dErr := s.dockerService.GetClient(ctx) + if dErr != nil { + return "", "", 0, fmt.Errorf("failed to connect to Docker: %w", dErr) + } + + if err := s.ensureRunnerImageInternal(ctx, dockerClient, runnerImage); err != nil { + return "", "", 0, fmt.Errorf("failed to ensure runner image %s: %w", runnerImage, err) + } + + // Resolve the workspace mount. When Arcane runs inside a container whose + // /app/data is backed by a named volume, a plain bind-mount of the + // translated host path (e.g. /var/lib/docker/volumes/.../_data/projects/X) + // is unreliable — Docker Desktop on WSL2 refuses it outright, and any + // daemon with non-trivial volume storage may behave differently. Using + // the named volume directly with VolumeOptions.Subpath sidesteps the + // translation entirely. For host-bind /app/data the helper returns a + // plain bind that points at the right host subdir. If Arcane is running + // on the host (no container inspect available), fall back to a bind on + // the project path as-is. + workspaceMount, mountErr := dockerutils.MountForCurrentContainerSubpath(ctx, dockerClient, projectPath, lifecycleWorkspaceMount) + if mountErr != nil { + slog.WarnContext(ctx, "failed to derive workspace mount; falling back to bind on project path", "projectPath", projectPath, "error", mountErr) + } + if workspaceMount == nil { + workspaceMount = &mounttypes.Mount{Type: mounttypes.TypeBind, Source: projectPath, Target: lifecycleWorkspaceMount} + } + + // Cmd is the script path alone — no interpreter wrapper. The script's + // shebang selects the interpreter (which must exist in the runner image), + // and the file must be executable on the host (git preserves +x through + // clone, so a committed +x script works transparently). This keeps + // Arcane out of the language-choice business and matches standard + // docker run semantics. + // + // Entrypoint is explicitly cleared because many purpose-built images set + // ENTRYPOINT to their primary tool (e.g. the official getsops/sops image + // has ENTRYPOINT=["sops"]). Without this override, our Cmd would become + // an argument to that tool rather than replacing it. + config := &containertypes.Config{ + Image: runnerImage, + Entrypoint: []string{}, + Cmd: []string{filepath.ToSlash(filepath.Join(lifecycleWorkspaceMount, scriptPath))}, + WorkingDir: lifecycleWorkspaceMount, + Env: envMapToSliceInternal(hookEnv), + AttachStdout: true, + AttachStderr: true, + Tty: false, + Labels: map[string]string{ + libarcane.InternalResourceLabel: "true", + }, + } + // no-new-privileges blocks the script (or anything it spawns) from + // gaining capabilities via setuid binaries inside the runner image. + // CapDrop ALL removes the default capability set Docker grants to root + // in a container (NET_RAW, NET_BIND_SERVICE, SETUID, etc.) — a hook + // script decrypting secrets or generating config has no need for any + // of them, and dropping them takes the most-common privilege-escalation + // primitives off the table even if a malicious script gets in. + hostConfig := &containertypes.HostConfig{ + Mounts: buildLifecycleMountsInternal(workspaceMount, extraMounts), + NetworkMode: resolveLifecycleNetworkModeInternal(networkMode), + SecurityOpt: []string{"no-new-privileges:true"}, + CapDrop: []string{"ALL"}, + AutoRemove: false, + } + + apiTimeoutSec := s.settingsService.GetIntSetting(ctx, "dockerApiTimeout", 0) + createCtx, createCancel := timeouts.WithTimeout(ctx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer createCancel() + resp, err := dockerClient.ContainerCreate(createCtx, client.ContainerCreateOptions{ + Config: config, + HostConfig: hostConfig, + }) + if err != nil { + return "", "", 0, fmt.Errorf("create lifecycle container: %w", err) + } + containerID := resp.ID + defer removeLifecycleContainerInternal(ctx, dockerClient, containerID, apiTimeoutSec) + + startCtx, startCancel := timeouts.WithTimeout(ctx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer startCancel() + if _, err := dockerClient.ContainerStart(startCtx, containerID, client.ContainerStartOptions{}); err != nil { + return "", "", 0, fmt.Errorf("start lifecycle container: %w", err) + } + + logsCtx, logsCancel := context.WithCancel(ctx) + defer logsCancel() + logs, err := dockerClient.ContainerLogs(logsCtx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + return "", "", 0, fmt.Errorf("stream lifecycle container logs: %w", err) + } + + stdoutBuf := newLifecycleCappedBufferInternal(lifecycleMaxOutputBytes) + stderrBuf := newLifecycleCappedBufferInternal(lifecycleMaxOutputBytes) + logDone := make(chan error, 1) + go func() { + _, copyErr := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) + logDone <- copyErr + }() + + waitCtx, waitCancel := context.WithTimeout(ctx, timeout) + defer waitCancel() + waitResp := dockerClient.ContainerWait(waitCtx, containerID, client.ContainerWaitOptions{ + Condition: containertypes.WaitConditionNotRunning, + }) + + select { + case result := <-waitResp.Result: + exitCode = result.StatusCode + if result.Error != nil && result.Error.Message != "" { + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + return stdoutBuf.String(), stderrBuf.String(), exitCode, fmt.Errorf("lifecycle container reported error: %s", result.Error.Message) + } + case waitErr := <-waitResp.Error: + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + if errors.Is(waitErr, context.DeadlineExceeded) || errors.Is(waitCtx.Err(), context.DeadlineExceeded) { + return stdoutBuf.String(), stderrBuf.String(), 0, fmt.Errorf("pre-deploy script timed out after %s: %w", timeout, context.DeadlineExceeded) + } + if errors.Is(waitErr, context.Canceled) { + return stdoutBuf.String(), stderrBuf.String(), 0, fmt.Errorf("pre-deploy script cancelled: %w", waitErr) + } + return stdoutBuf.String(), stderrBuf.String(), 0, fmt.Errorf("lifecycle container wait failed: %w", waitErr) + } + + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + + return stdoutBuf.String(), stderrBuf.String(), exitCode, nil +} + +// ensureRunnerImageInternal makes sure the runner image is available locally, +// pulling it on a miss. Mirrors the pattern used by VulnerabilityService for +// the Trivy scanner image, including reuse of the dockerImagePullTimeout +// setting so operators on slow networks can tune it once. +func (s *LifecycleService) ensureRunnerImageInternal(ctx context.Context, dockerClient *client.Client, image string) error { + if _, err := dockerClient.ImageInspect(ctx, image); err == nil { + return nil + } + + pullTimeoutSec := s.settingsService.GetIntSetting(ctx, "dockerImagePullTimeout", 0) + pullCtx, pullCancel := timeouts.WithTimeout(ctx, pullTimeoutSec, timeouts.DefaultDockerImagePull) + defer pullCancel() + + pullReader, err := dockerClient.ImagePull(pullCtx, image, client.ImagePullOptions{}) + if err != nil { + if errors.Is(pullCtx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("runner image pull timed out for %s (increase dockerImagePullTimeout setting if needed)", image) + } + return fmt.Errorf("pull runner image %s: %w", image, err) + } + defer func() { _ = pullReader.Close() }() + + if err := dockerutils.ConsumeJSONMessageStream(pullReader, nil); err != nil { + return fmt.Errorf("failed to complete runner image pull: %w", err) + } + return nil +} + +func (s *LifecycleService) resolveTimeoutInternal(ctx context.Context, perSyncTimeoutSec int) time.Duration { + timeoutSec := perSyncTimeoutSec + if timeoutSec <= 0 { + timeoutSec = lifecycleDefaultTimeoutSec + } + maxTimeoutSec := s.settingsService.GetIntSetting(ctx, "lifecycleMaxTimeoutSec", lifecycleDefaultMaxTimeoutSec) + if maxTimeoutSec > 0 && timeoutSec > maxTimeoutSec { + timeoutSec = maxTimeoutSec + } + return time.Duration(timeoutSec) * time.Second +} + +func (s *LifecycleService) persistLastRunInternal(ctx context.Context, syncID, status, output string, runAt time.Time) { + persistCtx := context.WithoutCancel(ctx) + err := s.db.WithContext(persistCtx). + Model(&models.GitOpsSync{}). + Where("id = ?", syncID). + Updates(map[string]any{ + "pre_deploy_last_run_at": runAt, + "pre_deploy_last_run_status": status, + "pre_deploy_last_run_output": output, + }).Error + if err != nil { + slog.WarnContext(ctx, "failed to persist lifecycle last-run state", "syncID", syncID, "error", err) + } +} + +func (s *LifecycleService) emitLifecycleEventInternal( + ctx context.Context, + project *models.Project, + sync *models.GitOpsSync, + status string, + exitCode int64, + durationMs int64, + runErr error, +) { + severity := models.EventSeveritySuccess + title := "Pre-deploy lifecycle hook succeeded: " + project.Name + description := fmt.Sprintf("Script %s exited with code %d in %dms", stringValueInternal(sync.PreDeployScriptPath), exitCode, durationMs) + if status != lifecycleStatusSuccess { + severity = models.EventSeverityWarning + title = fmt.Sprintf("Pre-deploy lifecycle hook %s: %s", status, project.Name) + if runErr != nil { + description = runErr.Error() + } + } + + metadata := models.JSON{ + "scriptPath": stringValueInternal(sync.PreDeployScriptPath), + "runnerImage": stringValueInternal(sync.PreDeployRunnerImage), + "exitCode": exitCode, + "durationMs": durationMs, + "gitopsSyncId": sync.ID, + "status": status, + } + + _, err := s.eventService.CreateEvent(ctx, CreateEventRequest{ + Type: models.EventTypeLifecycleExecute, + Severity: severity, + Title: title, + Description: description, + ResourceType: new("project"), + ResourceID: new(project.ID), + ResourceName: new(project.Name), + EnvironmentID: new(sync.EnvironmentID), + Metadata: metadata, + }) + if err != nil { + slog.WarnContext(ctx, "failed to emit lifecycle.execute event", "syncID", sync.ID, "error", err) + } +} + +// loadGitOpsSyncForProjectInternal returns the GitOps sync linked to the +// given project, or nil if the project is not GitOps-managed. A nil sync +// with nil error is a normal outcome, not a failure. +func (s *LifecycleService) loadGitOpsSyncForProjectInternal(ctx context.Context, projectID string) (*models.GitOpsSync, error) { + if projectID == "" { + return nil, nil + } + + var sync models.GitOpsSync + err := s.db.WithContext(ctx). + Where("project_id = ?", projectID). + First(&sync).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &sync, nil +} + +// validateScriptPathInternal rejects paths that escape the project directory +// or refer to symlinks/binaries. Reuses the same safety helper that gates +// the existing project include-file editor. +func validateScriptPathInternal(projectPath, scriptPath string) error { + if scriptPath == "" { + return errors.New("script path is empty") + } + // scriptPath is a POSIX repo path, not a host path; use path.IsAbs so the + // check behaves the same on Windows-based contributor machines. + if path.IsAbs(filepath.ToSlash(scriptPath)) { + return fmt.Errorf("script path %q must be relative to the project directory", scriptPath) + } + + absProject, err := filepath.Abs(projectPath) + if err != nil { + return fmt.Errorf("resolve project path: %w", err) + } + absScript, err := filepath.Abs(filepath.Join(absProject, scriptPath)) + if err != nil { + return fmt.Errorf("resolve script path: %w", err) + } + if !projects.IsSafeSubdirectory(absProject, absScript) { + return fmt.Errorf("script path %q escapes project directory", scriptPath) + } + + info, err := os.Lstat(absScript) + if err != nil { + return fmt.Errorf("stat script %q: %w", scriptPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("script path %q is a symlink; symlinks are not allowed", scriptPath) + } + if info.IsDir() { + return fmt.Errorf("script path %q refers to a directory", scriptPath) + } + return nil +} + +// parseLifecycleEnvTextInternal reads admin-configured env config as the +// same KEY=VALUE text format used by .env files: one entry per line, blank +// and "#"-prefixed lines ignored, keys must match POSIX identifier syntax. +// Reuses the strict parser also used for stdout-capture. +func parseLifecycleEnvTextInternal(raw *string) (map[string]string, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return map[string]string{}, nil + } + env, err := parseKeyValueEnvInternal(*raw) + if err != nil { + return nil, fmt.Errorf("invalid env entry: %w", err) + } + return env, nil +} + +// parseLifecycleExtraMountsTextInternal reads admin-configured bind mounts +// in docker-CLI "src:tgt[:ro|:rw]" form, one per line. Blank and +// "#"-prefixed lines are ignored. Both source and target must be absolute +// paths; mode defaults to read-write. +func parseLifecycleExtraMountsTextInternal(raw *string) ([]lifecycleExtraMount, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + + var mounts []lifecycleExtraMount + scanner := bufio.NewScanner(strings.NewReader(*raw)) + scanner.Buffer(make([]byte, 0, 4*1024), 64*1024) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, ":") + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("line %d: expected src:tgt[:ro|:rw], got %q", lineNum, line) + } + + mount := lifecycleExtraMount{Source: parts[0], Target: parts[1]} + if len(parts) == 3 { + switch parts[2] { + case "ro": + mount.Readonly = true + case "rw": + mount.Readonly = false + default: + return nil, fmt.Errorf("line %d: invalid mode %q (expected \"ro\" or \"rw\")", lineNum, parts[2]) + } + } + + // Mount sources/targets are interpreted by the Docker daemon as POSIX + // host/container paths, so use path.IsAbs to avoid host-OS quirks + // (filepath.IsAbs("/x") returns false on Windows). + if !path.IsAbs(filepath.ToSlash(mount.Source)) { + return nil, fmt.Errorf("line %d: source %q must be an absolute path", lineNum, mount.Source) + } + if !path.IsAbs(filepath.ToSlash(mount.Target)) { + return nil, fmt.Errorf("line %d: target %q must be an absolute path", lineNum, mount.Target) + } + + mounts = append(mounts, mount) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read extra mounts config: %w", err) + } + return mounts, nil +} + +// parseKeyValueEnvInternal parses stdout as a strict newline-separated list of +// KEY=VALUE pairs and returns them as a map. Blank lines and lines starting +// with '#' (after trimming leading whitespace) are ignored. Anything else is +// rejected — we'd rather fail a deploy than silently merge garbage into the +// compose env. +func parseKeyValueEnvInternal(stdout string) (map[string]string, error) { + env := map[string]string{} + scanner := bufio.NewScanner(strings.NewReader(stdout)) + scanner.Buffer(make([]byte, 0, 64*1024), lifecycleMaxOutputBytes) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimRight(scanner.Text(), "\r") + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx <= 0 { + return nil, fmt.Errorf("line %d: expected KEY=VALUE, got %q", lineNum, line) + } + key := line[:idx] + value := line[idx+1:] + if !lifecycleEnvKeyRegex.MatchString(key) { + return nil, fmt.Errorf("line %d: invalid env key %q", lineNum, key) + } + env[key] = value + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read stdout: %w", err) + } + return env, nil +} + +func envMapToSliceInternal(env map[string]string) []string { + if len(env) == 0 { + return nil + } + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} + +func buildLifecycleMountsInternal(workspace *mounttypes.Mount, extras []lifecycleExtraMount) []mounttypes.Mount { + mounts := make([]mounttypes.Mount, 0, 1+len(extras)) + // The project directory is mounted read-write so scripts can write + // artifacts the deploy then consumes — e.g. `sops -d secrets.enc.env > .env`. + // Anything written persists on the host until the next sync overwrites it. + // The workspace mount itself is built by MountForCurrentContainerSubpath + // so it carries the right Type (bind or volume) and any required + // VolumeOptions.Subpath. + mounts = append(mounts, *workspace) + for _, m := range extras { + mounts = append(mounts, mounttypes.Mount{ + Type: mounttypes.TypeBind, + Source: m.Source, + Target: m.Target, + ReadOnly: m.Readonly, + }) + } + return mounts +} + +// resolveLifecycleNetworkModeInternal maps the stored PreDeployNetworkMode +// string to the containertypes.NetworkMode value used by the Docker SDK. +// An empty stored value is treated as "none" so scripts never accidentally +// run on the default bridge network. +func resolveLifecycleNetworkModeInternal(mode string) containertypes.NetworkMode { + trimmed := strings.TrimSpace(mode) + if trimmed == "" { + trimmed = "none" + } + return containertypes.NetworkMode(trimmed) +} + +func lifecycleStatusForResultInternal(exitCode int64, runErr error) string { + if runErr != nil { + if errors.Is(runErr, context.DeadlineExceeded) { + return lifecycleStatusTimeout + } + return lifecycleStatusFailed + } + if exitCode != 0 { + return lifecycleStatusFailed + } + return lifecycleStatusSuccess +} + +func combineLifecycleOutputInternal(stdout, stderr string) string { + stdout = strings.TrimRight(stdout, "\n") + stderr = strings.TrimRight(stderr, "\n") + switch { + case stdout == "" && stderr == "": + return "" + case stderr == "": + return stdout + case stdout == "": + return "--- stderr ---\n" + stderr + default: + return stdout + "\n--- stderr ---\n" + stderr + } +} + +func truncateLifecycleOutputInternal(content string, maxBytes int) string { + if maxBytes <= 0 || len(content) <= maxBytes { + return content + } + return content[:maxBytes] + "\n..." +} + +func removeLifecycleContainerInternal(ctx context.Context, dockerClient *client.Client, containerID string, apiTimeoutSec int) { + if dockerClient == nil || containerID == "" { + return + } + cleanupCtx := context.WithoutCancel(ctx) + cleanupCtx, cleanupCancel := timeouts.WithTimeout(cleanupCtx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer cleanupCancel() + if _, err := dockerClient.ContainerRemove(cleanupCtx, containerID, client.ContainerRemoveOptions{Force: true}); err != nil && !cerrdefs.IsNotFound(err) { + slog.WarnContext(cleanupCtx, "failed to remove lifecycle container", "containerID", containerID, "error", err) + } +} + +// drainLifecycleLogsInternal waits for the log copy goroutine to finish with +// a bounded deadline, then force-closes the stream. Mirrors the trivy +// pattern, which exists to tolerate Docker variants that don't EOF cleanly +// when a container exits. +func drainLifecycleLogsInternal(ctx context.Context, logsCancel context.CancelFunc, logs io.ReadCloser, logDone <-chan error) { + timer := time.NewTimer(lifecycleStreamDrainTimeout) + defer timer.Stop() + select { + case <-logDone: + case <-timer.C: + slog.DebugContext(ctx, "lifecycle log stream did not close after container exit; force-closing") + } + logsCancel() + _ = logs.Close() +} + +func stringValueInternal(p *string) string { + if p == nil { + return "" + } + return *p +} + +// lifecycleCappedBuffer is an io.Writer that buffers up to maxBytes and +// silently drops the rest, recording the fact that it truncated. The String +// method returns the captured bytes with a truncation marker appended when +// applicable. Safe for concurrent use from a single stdcopy goroutine. +type lifecycleCappedBuffer struct { + mu sync.Mutex + buf bytes.Buffer + maxBytes int + truncated bool +} + +func newLifecycleCappedBufferInternal(maxBytes int) *lifecycleCappedBuffer { + return &lifecycleCappedBuffer{maxBytes: maxBytes} +} + +func (b *lifecycleCappedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.maxBytes <= 0 { + return len(p), nil + } + remaining := b.maxBytes - b.buf.Len() + if remaining <= 0 { + b.truncated = true + return len(p), nil + } + if len(p) <= remaining { + _, _ = b.buf.Write(p) + } else { + _, _ = b.buf.Write(p[:remaining]) + b.truncated = true + } + return len(p), nil +} + +func (b *lifecycleCappedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + s := b.buf.String() + if b.truncated { + s += "\n..." + } + return s +} diff --git a/backend/internal/services/lifecycle_service_test.go b/backend/internal/services/lifecycle_service_test.go new file mode 100644 index 0000000000..969b7148b1 --- /dev/null +++ b/backend/internal/services/lifecycle_service_test.go @@ -0,0 +1,418 @@ +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" +) + +func setupLifecycleTestDB(t *testing.T) *database.DB { + t.Helper() + db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.SettingVariable{}, + &models.Project{}, + &models.GitOpsSync{}, + &models.Event{}, + )) + return &database.DB{DB: db} +} + +func newLifecycleTestService(t *testing.T, db *database.DB) (*LifecycleService, *SettingsService) { + t.Helper() + settings, err := NewSettingsService(context.Background(), db) + require.NoError(t, err) + events := NewEventService(db, nil, nil) + return NewLifecycleService(db, settings, events, nil), settings +} + +func writeLifecycleProjectDirWithScript(t *testing.T, scriptRel, scriptBody string) string { + t.Helper() + dir := t.TempDir() + if scriptRel != "" { + full := filepath.Join(dir, scriptRel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(scriptBody), 0o644)) + } + return dir +} + +func TestParseLifecycleEnvText_Empty(t *testing.T) { + got, err := parseLifecycleEnvTextInternal(nil) + require.NoError(t, err) + assert.Empty(t, got) + + empty := " " + got, err = parseLifecycleEnvTextInternal(&empty) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestParseLifecycleEnvText_Valid(t *testing.T) { + raw := "FOO=bar\nBAZ_2=qux" + got, err := parseLifecycleEnvTextInternal(&raw) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FOO": "bar", "BAZ_2": "qux"}, got) +} + +func TestParseLifecycleEnvText_RejectsInvalidKeys(t *testing.T) { + cases := map[string]string{ + "leading digit": "1FOO=bar", + "dash": "FOO-BAR=baz", + "dot": "foo.bar=baz", + "no equals": "FOO bar", + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + _, err := parseLifecycleEnvTextInternal(&raw) + require.Error(t, err) + }) + } +} + +func TestParseLifecycleExtraMountsText_Empty(t *testing.T) { + got, err := parseLifecycleExtraMountsTextInternal(nil) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestParseLifecycleExtraMountsText_Valid(t *testing.T) { + raw := "/host/age.key:/age.key:ro\n/host/data:/data" + got, err := parseLifecycleExtraMountsTextInternal(&raw) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "/host/age.key", got[0].Source) + assert.Equal(t, "/age.key", got[0].Target) + assert.True(t, got[0].Readonly) + assert.Equal(t, "/host/data", got[1].Source) + assert.Equal(t, "/data", got[1].Target) + assert.False(t, got[1].Readonly) +} + +func TestParseLifecycleExtraMountsText_RejectsRelativePaths(t *testing.T) { + cases := []string{ + "relative/path:/in/container", + "/host/path:relative/target", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + _, err := parseLifecycleExtraMountsTextInternal(&raw) + require.Error(t, err) + }) + } +} + +func TestParseLifecycleExtraMountsText_RejectsInvalidMode(t *testing.T) { + raw := "/host/data:/data:wat" + _, err := parseLifecycleExtraMountsTextInternal(&raw) + require.Error(t, err) +} + +func TestParseKeyValueEnv_HappyPath(t *testing.T) { + stdout := "FOO=bar\n# comment line\n\nBAZ_2=hello world\n" + got, err := parseKeyValueEnvInternal(stdout) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FOO": "bar", "BAZ_2": "hello world"}, got) +} + +func TestParseKeyValueEnv_RejectsMalformedLines(t *testing.T) { + cases := map[string]string{ + "no equals": "FOO bar", + "empty key": "=value", + "bad key": "foo-bar=baz", + "digit start": "1FOO=bar", + } + for name, stdout := range cases { + t.Run(name, func(t *testing.T) { + _, err := parseKeyValueEnvInternal(stdout) + require.Error(t, err) + }) + } +} + +func TestParseKeyValueEnv_AllowsEqualsInValue(t *testing.T) { + stdout := "TOKEN=abc=def=ghi" + got, err := parseKeyValueEnvInternal(stdout) + require.NoError(t, err) + assert.Equal(t, "abc=def=ghi", got["TOKEN"]) +} + +func TestValidateScriptPath_HappyPath(t *testing.T) { + dir := writeLifecycleProjectDirWithScript(t, "scripts/pre-deploy.sh", "#!/bin/sh\necho hi\n") + err := validateScriptPathInternal(dir, "scripts/pre-deploy.sh") + require.NoError(t, err) +} + +func TestValidateScriptPath_RejectsAbsolute(t *testing.T) { + dir := t.TempDir() + err := validateScriptPathInternal(dir, "/etc/passwd") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsTraversal(t *testing.T) { + dir := t.TempDir() + err := validateScriptPathInternal(dir, "../escape.sh") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsMissingFile(t *testing.T) { + dir := t.TempDir() + err := validateScriptPathInternal(dir, "missing.sh") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "scripts"), 0o755)) + err := validateScriptPathInternal(dir, "scripts") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsSymlink(t *testing.T) { + if _, err := os.Stat("/tmp"); err != nil { + t.Skip("symlink test requires unix-like FS") + } + dir := t.TempDir() + target := filepath.Join(dir, "real.sh") + require.NoError(t, os.WriteFile(target, []byte("echo"), 0o644)) + link := filepath.Join(dir, "link.sh") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink in test env: %v", err) + } + err := validateScriptPathInternal(dir, "link.sh") + require.Error(t, err) +} + +func TestLifecycleStatusForResult(t *testing.T) { + assert.Equal(t, lifecycleStatusSuccess, lifecycleStatusForResultInternal(0, nil)) + assert.Equal(t, lifecycleStatusFailed, lifecycleStatusForResultInternal(1, nil)) + assert.Equal(t, lifecycleStatusFailed, lifecycleStatusForResultInternal(0, errors.New("boom"))) + assert.Equal(t, lifecycleStatusTimeout, lifecycleStatusForResultInternal(0, context.DeadlineExceeded)) +} + +func TestCombineLifecycleOutput(t *testing.T) { + cases := []struct { + stdout, stderr, want string + }{ + {"", "", ""}, + {"hello", "", "hello"}, + {"", "boom", "--- stderr ---\nboom"}, + {"hello\n", "boom\n", "hello\n--- stderr ---\nboom"}, + } + for _, c := range cases { + got := combineLifecycleOutputInternal(c.stdout, c.stderr) + assert.Equal(t, c.want, got, "stdout=%q stderr=%q", c.stdout, c.stderr) + } +} + +func TestTruncateLifecycleOutput(t *testing.T) { + short := "hello" + assert.Equal(t, short, truncateLifecycleOutputInternal(short, 1024)) + + long := strings.Repeat("a", 200) + got := truncateLifecycleOutputInternal(long, 100) + require.True(t, strings.HasPrefix(got, strings.Repeat("a", 100))) + require.Contains(t, got, "") +} + +func TestRunPreDeploy_NilProject(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + require.NoError(t, svc.RunPreDeploy(context.Background(), nil)) +} + +func TestRunPreDeploy_ProjectNotGitOpsManaged(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: nil, + } + require.NoError(t, svc.RunPreDeploy(context.Background(), project)) +} + +func TestRunPreDeploy_KillSwitchDisabled(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + + syncID := "sync-1" + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + scriptPath := "pre-deploy.sh" + runnerImage := "alpine:latest" + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + PreDeployScriptPath: &scriptPath, + PreDeployRunnerImage: &runnerImage, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + require.NoError(t, svc.RunPreDeploy(context.Background(), project)) +} + +func TestRunPreDeploy_NoScriptConfigured(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + syncID := "sync-1" + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + require.NoError(t, svc.RunPreDeploy(context.Background(), project)) +} + +func TestRunPreDeploy_MissingRunnerImage(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + syncID := "sync-1" + projectDir := writeLifecycleProjectDirWithScript(t, "pre-deploy.sh", "echo hi\n") + project := &models.Project{ + Name: "demo", + Path: projectDir, + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + scriptPath := "pre-deploy.sh" + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + PreDeployScriptPath: &scriptPath, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + err := svc.RunPreDeploy(context.Background(), project) + require.Error(t, err) + assert.Contains(t, err.Error(), "runner image") +} + +func TestRunPreDeploy_PathTraversalRejected(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + syncID := "sync-1" + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + scriptPath := "../etc/passwd" + runnerImage := "alpine:latest" + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + PreDeployScriptPath: &scriptPath, + PreDeployRunnerImage: &runnerImage, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + err := svc.RunPreDeploy(context.Background(), project) + require.Error(t, err) + assert.Contains(t, err.Error(), "script path") +} + +func TestLoadGitOpsSyncForProject_ReturnsNilWhenAbsent(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + sync, err := svc.loadGitOpsSyncForProjectInternal(context.Background(), "nonexistent") + require.NoError(t, err) + assert.Nil(t, sync) +} + +func TestPersistLastRun_UpdatesGitOpsSyncRow(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + } + sync.ID = "sync-1" + require.NoError(t, db.Create(sync).Error) + + now := time.Now().UTC().Truncate(time.Second) + svc.persistLastRunInternal(context.Background(), sync.ID, lifecycleStatusSuccess, "all good", now) + + var got models.GitOpsSync + require.NoError(t, db.Where("id = ?", sync.ID).First(&got).Error) + require.NotNil(t, got.PreDeployLastRunAt) + require.NotNil(t, got.PreDeployLastRunStatus) + require.NotNil(t, got.PreDeployLastRunOutput) + assert.Equal(t, lifecycleStatusSuccess, *got.PreDeployLastRunStatus) + assert.Equal(t, "all good", *got.PreDeployLastRunOutput) +} diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index 9bd012736a..91d86c38ef 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -42,13 +42,14 @@ import ( ) type ProjectService struct { - db *database.DB - settingsService *SettingsService - eventService *EventService - imageService *ImageService - dockerService *DockerClientService - buildService *BuildService - config *config.Config + db *database.DB + settingsService *SettingsService + eventService *EventService + imageService *ImageService + dockerService *DockerClientService + buildService *BuildService + lifecycleService *LifecycleService + config *config.Config composeNameCacheMu sync.RWMutex composeNameToProjID map[string]string @@ -62,16 +63,17 @@ type composeCacheEntry struct { project *composetypes.Project } -func NewProjectService(db *database.DB, settingsService *SettingsService, eventService *EventService, imageService *ImageService, dockerService *DockerClientService, buildService *BuildService, cfg *config.Config) *ProjectService { +func NewProjectService(db *database.DB, settingsService *SettingsService, eventService *EventService, imageService *ImageService, dockerService *DockerClientService, buildService *BuildService, lifecycleService *LifecycleService, cfg *config.Config) *ProjectService { return &ProjectService{ - db: db, - settingsService: settingsService, - eventService: eventService, - imageService: imageService, - dockerService: dockerService, - buildService: buildService, - config: cfg, - composeCache: cache.NewKeyed[string, composeCacheEntry](), + db: db, + settingsService: settingsService, + eventService: eventService, + imageService: imageService, + dockerService: dockerService, + buildService: buildService, + lifecycleService: lifecycleService, + config: cfg, + composeCache: cache.NewKeyed[string, composeCacheEntry](), } } @@ -1845,15 +1847,27 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us resolvedPullPolicy = "missing" } + if err := s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusDeploying); err != nil { + return fmt.Errorf("failed to update project status to deploying: %w", err) + } + + // Run any configured pre-deploy lifecycle hook before loading the compose + // project so hooks can produce files that compose then consumes (e.g. + // `sops -d secrets.enc.env > .env.runtime` for an `env_file: .env.runtime` + // service). A failed hook aborts the deploy. + if s.lifecycleService != nil { + if lerr := s.lifecycleService.RunPreDeploy(ctx, projectFromDb); lerr != nil { + s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) + return fmt.Errorf("pre-deploy lifecycle hook failed: %w", lerr) + } + } + project, _, derr := s.loadComposeProjectForProjectInternal(ctx, projectFromDb, nil) if derr != nil { + s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) return fmt.Errorf("failed to load compose project in %s: %w", projectFromDb.Path, derr) } - if err := s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusDeploying); err != nil { - return fmt.Errorf("failed to update project status to deploying: %w", err) - } - progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) if perr := s.prepareProjectImagesForDeploy(ctx, projectID, project, progressWriter, nil, &user, resolvedPullPolicy); perr != nil { s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index a352bf2acf..33961802f8 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -124,7 +124,7 @@ func TestProjectService_GetProjectFromDatabaseByID(t *testing.T) { // Setup dependencies settingsService, _ := NewSettingsService(ctx, db) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) // Create test project proj := &models.Project{ @@ -241,7 +241,7 @@ func TestProjectService_CalculateProjectStatus(t *testing.T) { func TestProjectService_UpdateProjectStatusInternal(t *testing.T) { db := setupProjectTestDB(t) ctx := context.Background() - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) proj := &models.Project{ BaseModel: models.BaseModel{ @@ -353,7 +353,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("exact match", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) proj := &models.Project{ BaseModel: models.BaseModel{ID: "p1"}, @@ -369,7 +369,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("normalized fallback", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) proj := &models.Project{ BaseModel: models.BaseModel{ID: "p1"}, @@ -385,7 +385,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("display name in db, normalized compose label input", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) display := &models.Project{ BaseModel: models.BaseModel{ID: "p2"}, @@ -401,7 +401,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("invalidates stale normalized cache entries after deletion", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) original := &models.Project{ BaseModel: models.BaseModel{ID: "p3"}, @@ -438,7 +438,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("invalidates stale normalized cache entries after rename", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) original := &models.Project{ BaseModel: models.BaseModel{ID: "p5"}, @@ -579,7 +579,7 @@ func TestProjectService_PullProjectImages_UpdatesCurrentImageRecordAfterPull(t * eventService := NewEventService(db, nil, nil) imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) projectPath := createComposeProjectDir(t, projectsDir, "compose-pull") composeContent := fmt.Sprintf("services:\n app:\n image: %s\n builder:\n build: .\n", imageRef) @@ -674,7 +674,7 @@ func TestProjectService_EnsureImagesPresent_UpdatesCurrentImageRecordAfterPull(t eventService := NewEventService(db, nil, nil) imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) require.NoError(t, db.Create(&models.ImageUpdateRecord{ ID: "sha256:old-api", @@ -726,7 +726,7 @@ func TestProjectService_PullImageForService_UpdatesCurrentImageRecordAfterPull(t eventService := NewEventService(db, nil, nil) imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) require.NoError(t, db.Create(&models.ImageUpdateRecord{ ID: "sha256:old-worker", @@ -775,7 +775,7 @@ func TestProjectService_ComposePullSelectedServicesInternal_ReconcilesOnlyOnSucc eventService := NewEventService(db, nil, nil) imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) projectDef := &composetypes.Project{ Name: "compose-selected", @@ -853,7 +853,7 @@ func TestProjectService_ComposePullSelectedServicesInternal_LeavesRecordsWhenPul dockerService := &DockerClientService{} imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, NewEventService(db, nil, nil)) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) projectDef := &composetypes.Project{ Name: "compose-selected", @@ -942,7 +942,7 @@ func TestProjectService_UpdateProjectServicesHardFailsWhenPullFailsInternal(t *t return errors.New("compose up should not run") } - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) require.Error(t, err) assert.ErrorContains(t, err, "pull updated service images") @@ -1004,7 +1004,7 @@ func TestProjectService_UpdateProjectServicesForcesRecreateInternal(t *testing.T return errors.New("compose up failed after assertion") } - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) require.Error(t, err) assert.True(t, upCalled) @@ -1022,7 +1022,7 @@ func TestProjectService_UpdateProject_RenamesDirectoryWhenNameChanges(t *testing require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -1070,7 +1070,7 @@ func TestProjectService_UpdateProject_RenameFailsWhenTargetDirectoryExists(t *te require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -1116,7 +1116,7 @@ func TestProjectService_UpdateProject_RenameFailsWhenProjectRunning(t *testing.T require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -1159,7 +1159,7 @@ func TestProjectService_UpdateProject_ValidatesComposeUsingExistingProjectName(t require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "demo" projectPath := filepath.Join(projectsDir, dirName) @@ -1201,7 +1201,7 @@ func TestProjectService_UpdateProject_AllowsMissingEnvFileDuringComposeValidatio require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-required" projectPath := filepath.Join(projectsDir, dirName) @@ -1245,7 +1245,7 @@ func TestProjectService_UpdateProject_AllowsMissingLocalIncludeDuringComposeVali require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-new" projectPath := filepath.Join(projectsDir, dirName) @@ -1309,7 +1309,7 @@ func TestProjectService_UpdateProject_RejectsMissingExternalIncludeDuringCompose require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-external" projectPath := filepath.Join(projectsDir, dirName) @@ -1353,7 +1353,7 @@ func TestProjectService_CreateProject_RejectsExternalInclude(t *testing.T) { require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644)) compose := `include: @@ -1389,7 +1389,7 @@ func TestProjectService_CreateProject_RejectsArrayPathInclude(t *testing.T) { require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644)) compose := `include: @@ -1427,7 +1427,7 @@ func TestProjectService_GetProjectFileContent_RejectsExternalInclude(t *testing. require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-read" projectPath := filepath.Join(projectsDir, dirName) @@ -1470,7 +1470,7 @@ func TestProjectService_GetProjectFileContent_RejectsSymlinkInclude(t *testing.T require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-symlink" projectPath := filepath.Join(projectsDir, dirName) @@ -1516,7 +1516,7 @@ func TestProjectService_GetProjectFileContent_RejectsIntermediateSymlinkInclude( require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-intermediate-symlink" projectPath := filepath.Join(projectsDir, dirName) @@ -1562,7 +1562,7 @@ func TestProjectService_UpdateProject_UsesExistingEnvFileDuringComposeValidation require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-existing" projectPath := filepath.Join(projectsDir, dirName) @@ -1608,7 +1608,7 @@ func TestProjectService_UpdateProject_UsesProvidedEnvContentDuringComposeValidat require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-updated" projectPath := filepath.Join(projectsDir, dirName) @@ -1654,7 +1654,7 @@ func TestProjectService_UpdateProject_ReturnsEnvParseErrorDuringComposeValidatio require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-invalid" projectPath := filepath.Join(projectsDir, dirName) @@ -1698,7 +1698,7 @@ func TestProjectService_UpdateProject_UsesGlobalEnvDuringComposeValidation(t *te require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "global-env-update" projectPath := filepath.Join(projectsDir, dirName) @@ -1746,7 +1746,7 @@ func TestProjectService_UpdateProject_DoesNotResolveHostEnvThroughGlobalEnvDurin require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "host-env-guard" projectPath := filepath.Join(projectsDir, dirName) @@ -1789,7 +1789,7 @@ func TestProjectService_UpdateProject_DerivesProjectOverrideEnvWhenGitSourceExis require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "override-edit" projectPath := filepath.Join(projectsDir, dirName) @@ -1835,7 +1835,7 @@ func TestProjectService_UpdateProject_DeletingGitBackedKeyFallsBackToGit(t *test require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "override-delete" projectPath := filepath.Join(projectsDir, dirName) @@ -1884,7 +1884,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_MigratesDirectEnvIntoProjectOve require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-migrate" projectPath := filepath.Join(projectsDir, dirName) @@ -1935,7 +1935,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_NormalizesStaleCopiedGitOverrid require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-normalize" projectPath := filepath.Join(projectsDir, dirName) @@ -1983,7 +1983,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_RemovesLegacyDeletedGitMasks(t require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-delete-mask" projectPath := filepath.Join(projectsDir, dirName) @@ -2032,7 +2032,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_RemovesGitEnvSource(t *testing. require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-remove" projectPath := filepath.Join(projectsDir, dirName) @@ -2076,7 +2076,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_UsesGlobalEnvDuringComposeValid require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-global-env" projectPath := filepath.Join(projectsDir, dirName) @@ -2123,7 +2123,7 @@ func TestProjectService_PersistGitSyncEnvFiles_UsesPreparedState(t *testing.T) { require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-prepared-state" projectPath := filepath.Join(projectsDir, dirName) @@ -2161,7 +2161,7 @@ func TestProjectService_GetProjectDetails_ReturnsEffectiveEnvContent(t *testing. require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "details-override" projectPath := filepath.Join(projectsDir, dirName) @@ -2288,7 +2288,7 @@ func TestProjectService_GetProjectDetails_IncludesUpdateInfo(t *testing.T) { require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) imageService := &ImageService{db: db} - svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, nil, config.Load()) projectPath := createComposeProjectDir(t, projectsDir, "updates-demo") require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: nginx:latest\n"), 0o644)) @@ -2387,7 +2387,7 @@ func TestProjectService_GetProjectDetails_RefreshesRuntimeStatusWithoutRuntimeSe } require.NoError(t, db.Create(projectRecord).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) details, err := svc.GetProjectDetails(ctx, projectRecord.ID, projecttypes.DetailsOptions{}) require.NoError(t, err) @@ -2439,7 +2439,7 @@ func TestProjectService_GetProjectDetails_PopulatesRuntimeServicesFromComposePs( } require.NoError(t, db.Create(projectRecord).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) details, err := svc.GetProjectDetails(ctx, projectRecord.ID, projecttypes.DetailsOptions{IncludeRuntimeServices: true}) require.NoError(t, err) @@ -2463,7 +2463,7 @@ func TestProjectService_ListProjects_FiltersByUpdateStatus(t *testing.T) { require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) imageService := &ImageService{db: db} - svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, nil, config.Load()) updatedPath := createComposeProjectDir(t, projectsDir, "updated-demo") require.NoError(t, os.WriteFile(filepath.Join(updatedPath, "compose.yaml"), []byte("services:\n app:\n image: nginx:latest\n"), 0o644)) @@ -2694,7 +2694,7 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { ArchivedAt: new(time.Now().UTC()), }).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ Params: pagination.Params{Limit: -1}, @@ -2746,7 +2746,7 @@ func TestProjectService_ArchiveProject_RequiresStoppedProject(t *testing.T) { RunningCount: 1, }).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) err = svc.ArchiveProject(ctx, "project-running", models.User{BaseModel: models.BaseModel{ID: "user-1"}, Username: "tester"}) require.Error(t, err) var stoppedErr *common.ProjectMustBeStoppedError @@ -2776,7 +2776,7 @@ func TestProjectService_ArchiveProject_TogglesArchiveFlag(t *testing.T) { Status: models.ProjectStatusStopped, }).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) user := models.User{BaseModel: models.BaseModel{ID: "user-1"}, Username: "tester"} require.NoError(t, svc.ArchiveProject(ctx, "project-stopped", user)) @@ -2910,7 +2910,7 @@ func TestProjectService_ListProjects_WithDerivedStatusFilter_AllowsAllPageSizeSe }).Error) } - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ Filters: map[string]string{ @@ -3216,7 +3216,7 @@ func TestProjectService_DeployProject_StopsOnBuildPreparationError(t *testing.T) require.NoError(t, db.Create(proj).Error) buildSvc := &BuildService{builder: testBuildBuilder{err: errors.New("boom build")}} - svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, nil, config.Load()) err = svc.DeployProject(ctx, "p1", models.User{BaseModel: models.BaseModel{ID: "u1"}, Username: "tester"}, nil) require.Error(t, err) @@ -3258,7 +3258,7 @@ func TestProjectService_DeployProject_BuildsGeneratedImageWithoutPull(t *testing require.NoError(t, db.Create(proj).Error) buildSvc := &BuildService{builder: testBuildBuilder{err: errors.New("boom build")}} - svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, nil, config.Load()) err = svc.DeployProject(ctx, proj.ID, models.User{BaseModel: models.BaseModel{ID: "u1"}, Username: "tester"}, nil) require.Error(t, err) @@ -3308,7 +3308,7 @@ func TestProjectService_SyncProjectsFromFileSystem_IgnoresSymlinkedProjectDirsWh require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3334,7 +3334,7 @@ func TestProjectService_SyncProjectsFromFileSystem_DetectsSymlinkedProjectDirsWh require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3359,7 +3359,7 @@ func TestProjectService_CountProjectFolders_RespectsFollowProjectSymlinks(t *tes require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false")) count, err := svc.countProjectFolders(ctx) @@ -3385,7 +3385,7 @@ func TestProjectService_SyncProjectsFromFileSystem_DiscoversNestedProjectsAndRel require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ @@ -3419,7 +3419,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RespectsConfiguredScanMaxDept require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) t.Setenv("PROJECT_SCAN_MAX_DEPTH", "1") - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3464,7 +3464,7 @@ services: require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ @@ -3491,7 +3491,7 @@ func TestProjectService_CountProjectFolders_RecursivelyCountsNestedProjects(t *t require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) count, err := svc.countProjectFolders(ctx) require.NoError(t, err) @@ -3512,7 +3512,7 @@ func TestProjectService_CountProjectFolders_RespectsConfiguredScanMaxDepth(t *te require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) t.Setenv("PROJECT_SCAN_MAX_DEPTH", "1") - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) count, err := svc.countProjectFolders(ctx) require.NoError(t, err) @@ -3531,7 +3531,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RemovesDeletedNestedProject(t require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3565,7 +3565,7 @@ func TestProjectService_SyncProjectsFromFileSystem_PreservesDBRecordsWhenDirecto require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) before, err := svc.ListAllProjects(ctx) @@ -3600,7 +3600,7 @@ func TestProjectService_SyncProjectsFromFileSystem_AllowsDuplicateLeafDirectorie require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) var items []models.Project @@ -3631,7 +3631,7 @@ func TestProjectService_SyncProjectsFromFileSystem_DetectsNestedSymlinkedProject require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ @@ -3662,7 +3662,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RemovesSymlinkedProjectsWhenD require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3692,7 +3692,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RefreshesServiceCountOnCompos require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) var project models.Project @@ -3746,7 +3746,7 @@ func TestProjectService_SyncProjectsFromFileSystem_PreservesGitOpsProjectWithCus require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3796,7 +3796,7 @@ func TestProjectService_GetProjectDetails_UsesGitOpsCustomComposeFilename(t *tes require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) composeFromContent, envFromContent, err := svc.GetProjectContent(ctx, syncProjectID) require.NoError(t, err) @@ -3828,7 +3828,7 @@ func TestProjectService_UpdateProject_WritesThroughSymlinkedProjectPath(t *testi require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) project := &models.Project{ BaseModel: models.BaseModel{ID: "proj-symlink-update"}, diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index a31da0bd3b..e12732c038 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -160,6 +160,8 @@ func DefaultSettingsConfig() *models.Settings { TrivyCpuLimit: models.SettingVariable{Value: "1"}, TrivyMemoryLimitMb: models.SettingVariable{Value: "0"}, TrivyConcurrentScanContainers: models.SettingVariable{Value: "1"}, + LifecycleEnabled: models.SettingVariable{Value: "false"}, + LifecycleMaxTimeoutSec: models.SettingVariable{Value: "300"}, OidcEnabled: models.SettingVariable{Value: "false"}, OidcClientId: models.SettingVariable{Value: ""}, OidcClientSecret: models.SettingVariable{Value: ""}, diff --git a/backend/internal/services/updater_service_test.go b/backend/internal/services/updater_service_test.go index c1e63877cd..f6918e3b2e 100644 --- a/backend/internal/services/updater_service_test.go +++ b/backend/internal/services/updater_service_test.go @@ -445,7 +445,7 @@ func TestUpdaterService_LazyRegisterComposeProjectInternal_AddsServicesForRegist } require.NoError(t, db.Create(project).Error) - projectService := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + projectService := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) svc := &UpdaterService{projectService: projectService} projectNameToID := map[string]string{} diff --git a/backend/pkg/dockerutil/mount_utils.go b/backend/pkg/dockerutil/mount_utils.go index 27d3f82ed8..fdebf037cb 100644 --- a/backend/pkg/dockerutil/mount_utils.go +++ b/backend/pkg/dockerutil/mount_utils.go @@ -91,6 +91,113 @@ func getCurrentContainerInspectTargetInternal(currentContainerID func() (string, return strings.TrimSpace(value), nil } +// MountForCurrentContainerSubpath inspects the current container, finds the +// existing mount whose destination covers containerPath, and returns a Mount +// suitable for use in another container creation that exposes the same data +// at target. Returns nil + no error if Arcane isn't running inside a +// container or no suitable mount is found — callers can fall back to a +// plain bind on containerPath in that case. +func MountForCurrentContainerSubpath(ctx context.Context, dockerCli *client.Client, containerPath, target string) (*mounttypes.Mount, error) { + if dockerCli == nil { + return nil, nil + } + inspectTarget, err := getCurrentContainerInspectTargetInternal(GetCurrentContainerID, os.Hostname) + if err != nil { + return nil, err + } + inspect, err := libarcane.ContainerInspectWithCompatibility(ctx, dockerCli, inspectTarget, client.ContainerInspectOptions{}) + if err != nil { + return nil, err + } + return MountForSubpath(inspect.Container.Mounts, containerPath, target), nil +} + +// MountForSubpath returns a Mount that exposes a subpath of one of the +// current container's existing mounts at the requested target. It's a +// generalisation of MountForDestination for the case where the caller +// wants a sub-tree below an existing mount destination (e.g. +// "/app/data/projects/X" when "/app/data" is what the container has +// mounted). +// +// The function picks the most-specific mount whose Destination is a +// prefix of containerPath, then constructs the Mount based on the +// backing type: +// +// - TypeBind: Source = mount.Source joined with the relative subpath. +// Works because bind sources are real host paths the daemon +// can address directly. +// - TypeVolume: Source = mount.Name (the volume name), and the relative +// subpath is set on VolumeOptions.Subpath. This lets the +// daemon mount the named volume directly without needing a +// host-side path translation — important for setups where +// the underlying volume storage is opaque (Docker Desktop +// on WSL2, Docker-in-Docker, etc.). +// +// Returns nil if no mount destination is a prefix of containerPath or if +// the matching mount is of an unsupported type. +func MountForSubpath(mounts []containertypes.MountPoint, containerPath string, target string) *mounttypes.Mount { + if strings.TrimSpace(containerPath) == "" { + return nil + } + if strings.TrimSpace(target) == "" { + target = containerPath + } + + var best *containertypes.MountPoint + for i := range mounts { + m := &mounts[i] + if m.Destination == "" { + continue + } + if !pathHasPrefix(containerPath, m.Destination) { + continue + } + if best == nil || len(m.Destination) > len(best.Destination) { + best = m + } + } + if best == nil { + return nil + } + + relative := strings.TrimPrefix(strings.TrimPrefix(containerPath, best.Destination), "/") + readOnly := !best.RW + + switch best.Type { //nolint:exhaustive // only bind and volume mounts are translatable; the default returns nil for the rest + case mounttypes.TypeBind: + if strings.TrimSpace(best.Source) == "" { + return nil + } + source := best.Source + if relative != "" { + source = strings.TrimRight(source, "/") + "/" + relative + } + return &mounttypes.Mount{Type: mounttypes.TypeBind, Source: source, Target: target, ReadOnly: readOnly} + case mounttypes.TypeVolume: + if strings.TrimSpace(best.Name) == "" { + return nil + } + m := &mounttypes.Mount{Type: mounttypes.TypeVolume, Source: best.Name, Target: target, ReadOnly: readOnly} + if relative != "" { + m.VolumeOptions = &mounttypes.VolumeOptions{Subpath: relative} + } + return m + default: + return nil + } +} + +// pathHasPrefix reports whether containerPath is at or under prefix, +// treating both as POSIX-style paths. Avoids false positives like +// "/app/datax" matching "/app/data". +func pathHasPrefix(containerPath, prefix string) bool { + if containerPath == prefix { + return true + } + p := strings.TrimRight(prefix, "/") + "/" + return strings.HasPrefix(containerPath, p) +} + // MountForDestination returns a Mount suitable for container creation that mirrors an // existing container mount at the given destination. // diff --git a/backend/pkg/dockerutil/mount_utils_test.go b/backend/pkg/dockerutil/mount_utils_test.go index a4eb6a472c..3e39b023d5 100644 --- a/backend/pkg/dockerutil/mount_utils_test.go +++ b/backend/pkg/dockerutil/mount_utils_test.go @@ -125,3 +125,85 @@ func TestMountForDestination(t *testing.T) { }) } } + +func TestMountForSubpath(t *testing.T) { + hostBind := containertypes.MountPoint{Type: mounttypes.TypeBind, Source: "/host/projects-root", Destination: "/app/data", RW: true} + namedVolume := containertypes.MountPoint{Type: mounttypes.TypeVolume, Name: "arcane-dev-data", Destination: "/app/data", RW: true} + nestedBind := containertypes.MountPoint{Type: mounttypes.TypeBind, Source: "/host/projects-only", Destination: "/app/data/projects", RW: true} + readOnlyVolume := containertypes.MountPoint{Type: mounttypes.TypeVolume, Name: "ro-vol", Destination: "/app/data", RW: false} + tmpfsMount := containertypes.MountPoint{Type: mounttypes.TypeTmpfs, Destination: "/app/data"} + + t.Run("bind mount with subpath joins host source", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeBind, got.Type) + require.Equal(t, "/host/projects-root/projects/foo", got.Source) + require.Equal(t, "/workspace", got.Target) + require.False(t, got.ReadOnly) + require.Nil(t, got.VolumeOptions) + }) + + t.Run("named volume with subpath uses VolumeOptions.Subpath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{namedVolume}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeVolume, got.Type) + require.Equal(t, "arcane-dev-data", got.Source) + require.Equal(t, "/workspace", got.Target) + require.NotNil(t, got.VolumeOptions) + require.Equal(t, "projects/foo", got.VolumeOptions.Subpath) + }) + + t.Run("picks the most-specific matching mount", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind, nestedBind}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + // nestedBind's destination is more specific, so the relative subpath is "foo" + require.Equal(t, "/host/projects-only/foo", got.Source) + }) + + t.Run("exact-destination match has no subpath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{namedVolume}, "/app/data", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeVolume, got.Type) + require.Equal(t, "arcane-dev-data", got.Source) + require.Nil(t, got.VolumeOptions, "exact destination match shouldn't add VolumeOptions") + }) + + t.Run("preserves read-only", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{readOnlyVolume}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.True(t, got.ReadOnly) + }) + + t.Run("defaults target to containerPath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/data/projects/foo", "") + require.NotNil(t, got) + require.Equal(t, "/app/data/projects/foo", got.Target) + }) + + t.Run("rejects unsupported mount types", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{tmpfsMount}, "/app/data/projects/foo", "/workspace")) + }) + + t.Run("returns nil when no mount destination is a prefix", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "/elsewhere/foo", "/workspace")) + }) + + t.Run("does not match similar-looking destinations", func(t *testing.T) { + // "/app/datax" must not match "/app/data". + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/datax/foo", "/workspace")) + }) + + t.Run("rejects empty containerPath", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "", "/workspace")) + }) + + t.Run("rejects bind mount with empty source", func(t *testing.T) { + bad := containertypes.MountPoint{Type: mounttypes.TypeBind, Destination: "/app/data"} + require.Nil(t, MountForSubpath([]containertypes.MountPoint{bad}, "/app/data/projects/foo", "/workspace")) + }) + + t.Run("rejects named volume with empty name", func(t *testing.T) { + bad := containertypes.MountPoint{Type: mounttypes.TypeVolume, Destination: "/app/data"} + require.Nil(t, MountForSubpath([]containertypes.MountPoint{bad}, "/app/data/projects/foo", "/workspace")) + }) +} diff --git a/backend/pkg/gitutil/git.go b/backend/pkg/gitutil/git.go index 9ff7e007d6..f44142ea0c 100644 --- a/backend/pkg/gitutil/git.go +++ b/backend/pkg/gitutil/git.go @@ -545,6 +545,11 @@ type SyncFileInfo struct { Content []byte Size int64 IsBinary bool + // Executable mirrors git's +x bit so callers can preserve it on the + // destination. Required for lifecycle hooks: a script committed as + // 100755 must arrive in the project workspace runnable, otherwise the + // lifecycle runner fails to exec it. + Executable bool } // DirectoryWalkResult holds the result of walking a directory for sync @@ -676,11 +681,16 @@ func (c *Client) appendSyncFile(root *os.Root, path string, d fs.DirEntry, resul return fmt.Errorf("total size limit exceeded (max %d bytes)", limits.maxTotalSize) } + executable := false + if info, err := d.Info(); err == nil { + executable = info.Mode()&0o111 != 0 + } result.Files = append(result.Files, SyncFileInfo{ RelativePath: path, Content: content, Size: fileSize, IsBinary: isBinary, + Executable: executable, }) result.TotalFiles++ result.TotalSize += fileSize diff --git a/backend/pkg/gitutil/git_test.go b/backend/pkg/gitutil/git_test.go index b6a3fb940b..a4ea21abf5 100644 --- a/backend/pkg/gitutil/git_test.go +++ b/backend/pkg/gitutil/git_test.go @@ -380,6 +380,38 @@ func TestWalkDirectory_BasicWalk(t *testing.T) { } } +func TestWalkDirectory_PreservesExecutableBit(t *testing.T) { + tmpDir := t.TempDir() + writeFileInternal(t, tmpDir, "compose.yaml", minimalCompose()) + writeFileInternal(t, tmpDir, "scripts/hook.sh", []byte("#!/bin/sh\necho hi\n")) + writeFileInternal(t, tmpDir, "README.md", []byte("readme")) + if err := os.Chmod(filepath.Join(tmpDir, "scripts/hook.sh"), 0o755); err != nil { + t.Fatalf("chmod: %v", err) + } + + client := NewClient("") + result, err := client.WalkDirectory(context.Background(), tmpDir, "compose.yaml", 0, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + byPath := map[string]SyncFileInfo{} + for _, f := range result.Files { + byPath[f.RelativePath] = f + } + + hook, ok := byPath[filepath.ToSlash("scripts/hook.sh")] + if !ok { + t.Fatalf("expected scripts/hook.sh in walk result, got %v", byPath) + } + if !hook.Executable { + t.Errorf("expected scripts/hook.sh to be reported Executable, got false") + } + if readme, ok := byPath["README.md"]; ok && readme.Executable { + t.Errorf("expected README.md to not be Executable") + } +} + func TestWalkDirectory_MaxFilesLimit(t *testing.T) { tmpDir := t.TempDir() writeFileInternal(t, tmpDir, "compose.yaml", minimalCompose()) diff --git a/backend/pkg/projects/fs_writer.go b/backend/pkg/projects/fs_writer.go index acf7328846..6466c1c65a 100644 --- a/backend/pkg/projects/fs_writer.go +++ b/backend/pkg/projects/fs_writer.go @@ -214,6 +214,9 @@ func WriteFileWithPerm(filePath, content string, perm os.FileMode) error { type SyncFile struct { RelativePath string // Path relative to the project directory Content []byte + // Executable preserves the source's +x bit so lifecycle hooks and other + // repo-committed scripts arrive runnable in the project workspace. + Executable bool } // WriteSyncedDirectory writes multiple files to a project directory. @@ -268,10 +271,20 @@ func WriteSyncedDirectory(projectsRoot, projectPath string, files []SyncFile) ([ return nil, fmt.Errorf("failed to inspect target path for %s: %w", file.RelativePath, err) } - // Write the file - if err := os.WriteFile(targetPathClean, file.Content, pkgutils.FilePerm); err != nil { + // Write the file. Honor the source's executable bit so scripts arrive + // runnable for lifecycle hooks and similar consumers. + perm := pkgutils.FilePerm + if file.Executable { + perm = 0o755 + } + if err := os.WriteFile(targetPathClean, file.Content, perm); err != nil { return nil, fmt.Errorf("failed to write file %s: %w", file.RelativePath, err) } + // os.WriteFile only honors the mode on file creation. Re-chmod so an + // update path also lifts/lowers the +x bit to match the repo. + if err := os.Chmod(targetPathClean, perm); err != nil { + return nil, fmt.Errorf("failed to set mode on %s: %w", file.RelativePath, err) + } writtenPaths = append(writtenPaths, file.RelativePath) } diff --git a/backend/pkg/projects/fs_writer_test.go b/backend/pkg/projects/fs_writer_test.go index ac3763d615..350b1cac92 100644 --- a/backend/pkg/projects/fs_writer_test.go +++ b/backend/pkg/projects/fs_writer_test.go @@ -137,6 +137,59 @@ func TestWriteComposeFile_PreservesExistingPodmanComposeNames(t *testing.T) { } } +func TestWriteSyncedDirectory_HonorsExecutableBit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode bits are not represented on Windows FS") + } + + root := t.TempDir() + project := filepath.Join(root, "myproject") + files := []SyncFile{ + {RelativePath: "compose.yml", Content: []byte("services:\n app:\n image: alpine\n"), Executable: false}, + {RelativePath: "scripts/pre-deploy.sh", Content: []byte("#!/bin/sh\necho hi\n"), Executable: true}, + {RelativePath: "README.md", Content: []byte("readme"), Executable: false}, + } + + _, err := WriteSyncedDirectory(root, project, files) + require.NoError(t, err) + + composeInfo, err := os.Stat(filepath.Join(project, "compose.yml")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0), composeInfo.Mode().Perm()&0o111, "compose.yml should not be executable") + + scriptInfo, err := os.Stat(filepath.Join(project, "scripts/pre-deploy.sh")) + require.NoError(t, err) + assert.NotEqual(t, os.FileMode(0), scriptInfo.Mode().Perm()&0o111, "scripts/pre-deploy.sh should be executable") +} + +func TestWriteSyncedDirectory_DowngradesExecutableBit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode bits are not represented on Windows FS") + } + + root := t.TempDir() + project := filepath.Join(root, "myproject") + // First write: script committed as +x. + _, err := WriteSyncedDirectory(root, project, []SyncFile{ + {RelativePath: "scripts/hook.sh", Content: []byte("#!/bin/sh\n"), Executable: true}, + }) + require.NoError(t, err) + first, err := os.Stat(filepath.Join(project, "scripts/hook.sh")) + require.NoError(t, err) + require.NotEqual(t, os.FileMode(0), first.Mode().Perm()&0o111) + + // Second write: same file, now without +x (e.g. the repo dropped the bit). + // The write path must re-chmod so the on-disk mode tracks the repo, not + // the previous write. + _, err = WriteSyncedDirectory(root, project, []SyncFile{ + {RelativePath: "scripts/hook.sh", Content: []byte("#!/bin/sh\necho updated\n"), Executable: false}, + }) + require.NoError(t, err) + second, err := os.Stat(filepath.Join(project, "scripts/hook.sh")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0), second.Mode().Perm()&0o111, "executable bit should clear on update when repo no longer marks +x") +} + func TestWriteComposeFile_PreservesExistingCustomComposeNames(t *testing.T) { t.Parallel() diff --git a/backend/pkg/projects/load.go b/backend/pkg/projects/load.go index 6fbeae7343..23e3da7a33 100644 --- a/backend/pkg/projects/load.go +++ b/backend/pkg/projects/load.go @@ -248,6 +248,7 @@ func loadComposeProjectInternal( slog.WarnContext(ctx, "Failed to load environment", "error", err) } + // Override wins: maps.Copy(dst, src) copies src into dst. maps.Copy(fullEnvMap, envOverride) // Set PWD diff --git a/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql new file mode 100644 index 0000000000..b1839bfd70 --- /dev/null +++ b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_output; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_status; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_at; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_network_mode; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_timeout_sec; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_extra_mounts; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_env; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_runner_image; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_script_path; diff --git a/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql new file mode 100644 index 0000000000..28f79afce7 --- /dev/null +++ b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql @@ -0,0 +1,20 @@ +-- Add pre-deploy lifecycle hook fields to gitops_syncs table. +-- See backend/internal/models/gitops_sync.go for field semantics. +-- pre_deploy_script_path: path inside the synced directory to a script +-- executed in a throwaway container before each deploy of the linked project. +-- pre_deploy_runner_image: image used to run the script (required when script path is set). +-- pre_deploy_env: newline-separated KEY=VALUE pairs exposed to the script as env vars. +-- pre_deploy_extra_mounts: newline-separated src:tgt[:ro|:rw] bind mounts. +-- pre_deploy_timeout_sec: hard timeout for script execution. +-- pre_deploy_network_mode: Docker network mode for the runner container; "none" denies outbound. +-- pre_deploy_last_run_*: last-run state, written by the lifecycle service. + +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_script_path TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_runner_image TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_env TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_extra_mounts TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_timeout_sec INTEGER NOT NULL DEFAULT 60; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_network_mode TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_at TIMESTAMPTZ; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_status TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_output TEXT; diff --git a/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql new file mode 100644 index 0000000000..b1839bfd70 --- /dev/null +++ b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_output; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_status; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_at; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_network_mode; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_timeout_sec; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_extra_mounts; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_env; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_runner_image; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_script_path; diff --git a/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql new file mode 100644 index 0000000000..6931cbb34e --- /dev/null +++ b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql @@ -0,0 +1,20 @@ +-- Add pre-deploy lifecycle hook fields to gitops_syncs table. +-- See backend/internal/models/gitops_sync.go for field semantics. +-- pre_deploy_script_path: path inside the synced directory to a script +-- executed in a throwaway container before each deploy of the linked project. +-- pre_deploy_runner_image: image used to run the script (required when script path is set). +-- pre_deploy_env: newline-separated KEY=VALUE pairs exposed to the script as env vars. +-- pre_deploy_extra_mounts: newline-separated src:tgt[:ro|:rw] bind mounts. +-- pre_deploy_timeout_sec: hard timeout for script execution. +-- pre_deploy_network_mode: Docker network mode for the runner container; "none" denies outbound. +-- pre_deploy_last_run_*: last-run state, written by the lifecycle service. + +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_script_path TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_runner_image TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_env TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_extra_mounts TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_timeout_sec INTEGER NOT NULL DEFAULT 60; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_network_mode TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_at DATETIME; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_status TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_output TEXT; diff --git a/types/gitops/gitops.go b/types/gitops/gitops.go index 011f5da7fe..e10ac33bd8 100644 --- a/types/gitops/gitops.go +++ b/types/gitops/gitops.go @@ -168,6 +168,64 @@ type GitOpsSync struct { // Required: false LastSyncCommit *string `json:"lastSyncCommit,omitempty"` + // PreDeployScriptPath is the optional path inside the synced repo to a + // script executed in a throwaway container before each deploy. Scripts + // are repo-trusted code; the configured runner image, env, and mounts + // shape the runtime context (admin-managed, never from repo data). + // + // Required: false + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty"` + + // PreDeployRunnerImage is the image used to run the pre-deploy script. + // Required whenever PreDeployScriptPath is set. + // + // Required: false + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty"` + + // PreDeployEnv is the KEY=VALUE env config exposed to the script, one + // entry per line; same format as a .env file. + // + // Required: false + PreDeployEnv *string `json:"preDeployEnv,omitempty"` + + // PreDeployExtraMounts is the bind-mount config added to the runner + // container, one entry per line in docker -v "src:tgt[:ro|:rw]" form. + // + // Required: false + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty"` + + // PreDeployTimeoutSec bounds the script execution. Capped by the + // lifecycleMaxTimeoutSec global setting at run time. + // + // Required: true + PreDeployTimeoutSec int `json:"preDeployTimeoutSec"` + + // PreDeployNetworkMode is the Docker network mode passed to the runner + // container. Defaults to "none" so scripts run with no network access + // unless explicitly opted in. Set to "bridge", "host", or a named + // network when the script needs outbound or compose-network access. + // + // Required: true + PreDeployNetworkMode string `json:"preDeployNetworkMode"` + + // PreDeployLastRunAt is the timestamp of the most recent pre-deploy + // lifecycle hook run on this sync. + // + // Required: false + PreDeployLastRunAt *time.Time `json:"preDeployLastRunAt,omitempty"` + + // PreDeployLastRunStatus is the status of the most recent pre-deploy + // lifecycle hook run: "success", "failed", or "timeout". + // + // Required: false + PreDeployLastRunStatus *string `json:"preDeployLastRunStatus,omitempty"` + + // PreDeployLastRunOutput is the truncated combined stdout+stderr from + // the most recent pre-deploy lifecycle hook run. + // + // Required: false + PreDeployLastRunOutput *string `json:"preDeployLastRunOutput,omitempty"` + // CreatedAt is the date and time at which the sync was created. // // Required: true @@ -367,6 +425,47 @@ type CreateSyncRequest struct { // // Required: false MaxSyncBinarySize *int64 `json:"maxSyncBinarySize,omitempty"` + + // PreDeployScriptPath is the optional path inside the synced repo to a + // script executed in a throwaway container before each deploy. When set, + // PreDeployRunnerImage must also be supplied. + // + // Required: false + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty"` + + // PreDeployRunnerImage is the image used to run the pre-deploy script. + // Required whenever PreDeployScriptPath is set. + // + // Required: false + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty"` + + // PreDeployEnv is the env config exposed to the script, one KEY=VALUE + // entry per line; same format as a .env file. Keys must match POSIX + // identifier syntax. + // + // Required: false + PreDeployEnv *string `json:"preDeployEnv,omitempty"` + + // PreDeployExtraMounts is the bind-mount config added to the runner + // container, one entry per line in docker -v "src:tgt[:ro|:rw]" form. + // Source and target must be absolute paths. + // + // Required: false + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty"` + + // PreDeployTimeoutSec bounds the script execution. Capped by the + // lifecycleMaxTimeoutSec global setting at validation time. Defaults to 60. + // + // Required: false + PreDeployTimeoutSec *int `json:"preDeployTimeoutSec,omitempty"` + + // PreDeployNetworkMode is the Docker network mode for the runner + // container. Defaults to "none" (no network access). Set to "bridge", + // "host", or a named Docker network to grant outbound or compose-network + // access. + // + // Required: false + PreDeployNetworkMode *string `json:"preDeployNetworkMode,omitempty"` } // UpdateSyncRequest represents the request to update a gitops sync. @@ -435,6 +534,46 @@ type UpdateSyncRequest struct { // // Required: false MaxSyncBinarySize *int64 `json:"maxSyncBinarySize,omitempty"` + + // PreDeployScriptPath is the optional path inside the synced repo to a + // script executed in a throwaway container before each deploy. Set to + // an empty string to clear an existing configuration. + // + // Required: false + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty"` + + // PreDeployRunnerImage is the image used to run the pre-deploy script. + // Required whenever PreDeployScriptPath is non-empty. + // + // Required: false + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty"` + + // PreDeployEnv is the env config exposed to the script, one KEY=VALUE + // entry per line; same format as a .env file. Keys must match POSIX + // identifier syntax. + // + // Required: false + PreDeployEnv *string `json:"preDeployEnv,omitempty"` + + // PreDeployExtraMounts is the bind-mount config added to the runner + // container, one entry per line in docker -v "src:tgt[:ro|:rw]" form. + // Source and target must be absolute paths. + // + // Required: false + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty"` + + // PreDeployTimeoutSec bounds the script execution. Capped by the + // lifecycleMaxTimeoutSec global setting at validation time. + // + // Required: false + PreDeployTimeoutSec *int `json:"preDeployTimeoutSec,omitempty"` + + // PreDeployNetworkMode is the Docker network mode for the runner + // container. Set to "none", "bridge", "host", or a named Docker + // network. Empty string resets to the default ("none"). + // + // Required: false + PreDeployNetworkMode *string `json:"preDeployNetworkMode,omitempty"` } // SyncResult represents the result of a sync operation. diff --git a/types/settings/settings.go b/types/settings/settings.go index 3affb21efd..ae7e4090d1 100644 --- a/types/settings/settings.go +++ b/types/settings/settings.go @@ -197,6 +197,19 @@ type Update struct { // Required: false GitSyncMaxBinarySizeMb *string `json:"gitSyncMaxBinarySizeMb,omitempty"` + // LifecycleEnabled gates whether GitOps syncs may configure pre-deploy + // lifecycle scripts. Disabled by default because scripts are repo-trusted + // code that runs on every deploy. + // + // Required: false + LifecycleEnabled *string `json:"lifecycleEnabled,omitempty"` + + // LifecycleMaxTimeoutSec caps the per-sync pre-deploy timeout admins can + // configure. Zero disables the cap. + // + // Required: false + LifecycleMaxTimeoutSec *string `json:"lifecycleMaxTimeoutSec,omitempty"` + // BaseServerURL is the base URL of the server. // // Required: false From c02cba4c63be621b09af49248806f3c5c2abe634 Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Mon, 1 Jun 2026 11:38:50 +1000 Subject: [PATCH 35/40] feat(lifecycle): pre-deploy hook UI for gitops syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the lifecycle hook backend through the GitOps sync dialog, project view, and surrounding surfaces. Add/edit sync dialog (gitops-sync-dialog.svelte) - New collapsible "Pre-deploy script" section with an acknowledgement banner (this is repo-trusted code that runs on every deploy), script path with file-browser picker, runner image, timeout, network, env vars (KEY=VALUE per line), and extra bind mounts. - File-browser opens scoped to the compose-dir so the user can only pick files that will actually exist in the deploy-time workspace. - Hard-rejects "Sync entire directory" being off while a script is configured (the validator returns a field-attributed error surfaced via the toast description). File browser (file-browser-dialog.svelte) - Generalised away from "compose-only": accepts a fileFilter predicate, a fileBadge Snippet, a footerHint Snippet, and a rootPath that scopes the browse subtree (blocks navigation upward, paths returned relative to the root). Compose-mode and script-mode are now just call-site configurations. Title + description are also overridable. Validation toast (gitops/+page.svelte) - Splits create/update failures into title + description so the field-attributed validator messages now reach the description line. Project view (projects/[projectId]/+page.svelte) - Adds a single inline line on the existing git-managed banner: `Pre-deploy script: (image: ..., network: ...)` — rendered only when a hook is configured. - Classic-layout file-tab row surfaces the script alongside compose includes; clicking it renders the script content read-only. Sync table (sync-table.svelte) - LifecycleIndicator component (CodeIcon + tooltip with path) marks rows that have a hook configured. Form input refactor (form/form-input.svelte) - Adds an `inputClass` prop forwarded to the underlying Input / Textarea so the new env-vars / extra-mounts textareas can use font-mono for their code-like content without losing the label/helpText/error wiring FormInput already provides. i18n (messages/en.json) and types (lib/types/gitops.type.ts, query/query-keys.ts) plumbed for the above. --- frontend/messages/en.json | 24 +++ .../dialogs/file-browser-dialog.svelte | 114 +++++++----- .../dialogs/gitops-sync-dialog.svelte | 167 +++++++++++++++++- .../src/lib/components/form/form-input.svelte | 8 +- .../components/lifecycle-indicator/index.ts | 1 + .../lifecycle-indicator.svelte | 28 +++ frontend/src/lib/query/query-keys.ts | 3 +- frontend/src/lib/types/automation.ts | 21 +++ .../environments/[id]/gitops/+page.svelte | 8 +- .../[id]/gitops/sync-table.svelte | 18 +- .../(app)/projects/[projectId]/+page.svelte | 99 +++++++++-- 11 files changed, 414 insertions(+), 77 deletions(-) create mode 100644 frontend/src/lib/components/lifecycle-indicator/index.ts create mode 100644 frontend/src/lib/components/lifecycle-indicator/lifecycle-indicator.svelte diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2b11fe2813..16aa25bb56 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1859,6 +1859,7 @@ "git_sync_import_open_button": "Import .JSON", "git_sync_browse_files_title": "Browse Repository Files", "git_sync_browse_files_description": "Select a compose file from the repository", + "git_sync_browse_files_description_script": "Select an executable script from the repository", "git_sync_browse_root": "Root", "git_sync_browse_no_files": "No files found", "git_sync_browse_compose_label": "Compose", @@ -1885,6 +1886,29 @@ "git_sync_max_files_per_sync_help": "Maximum files for this sync. Set 0 for no cap.", "git_sync_max_total_size_per_sync_help": "Maximum combined size for this sync. Set 0 for no cap.", "git_sync_max_binary_size_per_sync_help": "Maximum binary file size for this sync. Set 0 for no cap.", + "git_sync_pre_deploy_title": "Pre-deploy script", + "git_sync_pre_deploy_description": "Run a script from this repo before each deploy. Useful for decrypting secrets, generating config, or preparing the deploy environment.", + "git_sync_pre_deploy_script_path_label": "Script path", + "git_sync_pre_deploy_script_path_placeholder": "scripts/pre-deploy.sh", + "git_sync_pre_deploy_script_path_help": "Path within the synced directory (i.e. next to your compose file). The script's first line picks the interpreter.", + "git_sync_pre_deploy_runner_image_label": "Runner image", + "git_sync_pre_deploy_runner_image_placeholder": "alpine:latest", + "git_sync_pre_deploy_runner_image_help": "Docker image used to run the script. Required when a script path is set.", + "git_sync_pre_deploy_timeout_label": "Timeout (seconds)", + "git_sync_pre_deploy_timeout_help": "Maximum time the script is allowed to run.", + "git_sync_pre_deploy_network_mode_label": "Network", + "git_sync_pre_deploy_network_mode_placeholder": "none", + "git_sync_pre_deploy_network_mode_help": "Network the script can reach. Default \"none\" blocks all network access. Use \"bridge\" for outbound internet, or a Docker network name.", + "git_sync_pre_deploy_env_label": "Environment variables", + "git_sync_pre_deploy_env_placeholder": "# Example:\nFOO=bar\nDB_HOST=db.internal", + "git_sync_pre_deploy_env_help": "Env vars the script can read, one KEY=VALUE per line. Same format as a .env file.", + "git_sync_pre_deploy_extra_mounts_label": "Extra mounts", + "git_sync_pre_deploy_extra_mounts_placeholder": "# Example:\n/host/path:/container/path:ro", + "git_sync_pre_deploy_extra_mounts_help": "Host bind mounts added to the runner container, one per line in host_path:container_path[:ro|:rw] form.", + "git_sync_pre_deploy_acknowledgement": "Arcane runs this script on every deploy. Anyone who can push to the repo can change what it does.", + "lifecycle_indicator_tooltip": "Runs a pre-deploy script: {path}", + "lifecycle_inline_label": "Pre-deploy script", + "lifecycle_inline_runner_summary": "(image: {image}, network: {network})", "common_syncing": "Syncing...", "_comment_customize_variables": "=== CUSTOMIZATION - VARIABLES ===", "variables_title": "Variables", diff --git a/frontend/src/lib/components/dialogs/file-browser-dialog.svelte b/frontend/src/lib/components/dialogs/file-browser-dialog.svelte index 4c7999afe8..cbb9b4d6e4 100644 --- a/frontend/src/lib/components/dialogs/file-browser-dialog.svelte +++ b/frontend/src/lib/components/dialogs/file-browser-dialog.svelte @@ -9,18 +9,55 @@ import { FolderOpenIcon, FileTextIcon, ArrowRightIcon } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import { createQuery } from '@tanstack/svelte-query'; + import type { Snippet } from 'svelte'; type FileBrowserDialogProps = { open: boolean; repositoryId: string; branch: string; onSelect: (filePath: string) => void; + // title/description override the dialog heading. Defaults are generic so the + // component carries no domain assumption (compose vs script vs anything else). + title?: string; + description?: string; + // rootPath scopes browsing to a subdirectory of the repo. Navigation cannot + // escape this root, the breadcrumb hides everything above it, and the + // onSelect callback receives a path relative to this root. Use this when + // the selectable file must live inside a specific subtree (e.g. a script + // must live inside the sync directory so the deploy-time runner can find + // it). Default empty = full repo. + rootPath?: string; + // fileFilter decides which files are selectable. Directories are + // always navigable. Default accepts everything; callers narrow it. + fileFilter?: (file: FileTreeNode) => boolean; + // Optional UI decorations rendered per file (badge) and beneath the + // list (hint). Callers compose these to add domain-specific affordances + // (e.g. a "COMPOSE" badge + an explanatory hint) without the dialog + // knowing anything about the domain. + fileBadge?: Snippet<[FileTreeNode]>; + footerHint?: Snippet; }; - let { open = $bindable(false), repositoryId, branch, onSelect }: FileBrowserDialogProps = $props(); + let { + open = $bindable(false), + repositoryId, + branch, + onSelect, + title = m.git_sync_browse_files_title(), + description = m.git_sync_browse_files_description(), + rootPath = '', + fileFilter = () => true, + fileBadge, + footerHint + }: FileBrowserDialogProps = $props(); - let currentPath = $state(''); - let pathSegments = $derived(currentPath.split('/').filter(Boolean)); + // Internal navigation is stored as a path relative to the dialog's root, so the + // query key and breadcrumb stay consistent the moment the dialog opens — there's + // no race between "set currentPath" and "query enables when open=true". + let userPath = $state(''); + const normalizedRoot = $derived(rootPath.replace(/^\/+|\/+$/g, '')); + const currentPath = $derived(joinPath(normalizedRoot, userPath)); + const pathSegments = $derived(userPath.split('/').filter(Boolean)); const fileTreeQuery = createQuery<{ files: FileTreeNode[] }>(() => ({ queryKey: queryKeys.gitRepositories.files(repositoryId, branch, currentPath), queryFn: () => gitRepositoryService.browseFiles(repositoryId, branch, currentPath), @@ -29,35 +66,40 @@ })); const files = $derived(fileTreeQuery.data?.files ?? []); const loading = $derived(fileTreeQuery.isPending || fileTreeQuery.isFetching); + const atRoot = $derived(userPath === ''); - function loadFiles(path: string = '') { - currentPath = path; + function joinPath(a: string, b: string): string { + if (!a) return b; + if (!b) return a; + return `${a}/${b}`; + } + + function pathRelativeToRoot(absolutePath: string): string { + if (!normalizedRoot) return absolutePath; + if (absolutePath === normalizedRoot) return ''; + if (absolutePath.startsWith(normalizedRoot + '/')) { + return absolutePath.slice(normalizedRoot.length + 1); + } + return absolutePath; } function handleFileClick(file: FileTreeNode) { if (file.type === 'directory') { - loadFiles(file.path); - } else { - // Only allow selecting compose files - if (file.name.endsWith('.yml') || file.name.endsWith('.yaml')) { - onSelect(file.path); - open = false; - } + userPath = pathRelativeToRoot(file.path); + return; + } + if (fileFilter(file)) { + onSelect(pathRelativeToRoot(file.path)); + open = false; } } function goToPath(index: number) { - const newPath = pathSegments.slice(0, index + 1).join('/'); - loadFiles(newPath); + userPath = pathSegments.slice(0, index + 1).join('/'); } function goBack() { - const segments = pathSegments.slice(0, -1); - loadFiles(segments.join('/')); - } - - function isComposeFile(fileName: string): boolean { - return fileName.endsWith('.yml') || fileName.endsWith('.yaml'); + userPath = pathSegments.slice(0, -1).join('/'); } @@ -65,19 +107,20 @@ bind:open onOpenChange={(isOpen) => { if (isOpen && repositoryId && branch) { - currentPath = ''; + userPath = ''; } }} - title={m.git_sync_browse_files_title()} - description={m.git_sync_browse_files_description()} + {title} + {description} contentClass="max-w-2xl" >
- +
- {#each pathSegments as segment, index (`${index}-${segment}`)} @@ -97,7 +140,7 @@
{m.git_sync_browse_no_files()}
{:else}
- {#if currentPath !== ''} + {#if !atRoot} {/each}
{/if} -

- {m.git_sync_browse_hint()} -

+ {@render footerHint?.()}
{#snippet footer()} diff --git a/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte b/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte index 623d4a2956..2770340959 100644 --- a/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte +++ b/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte @@ -10,7 +10,7 @@ import { Switch } from '$lib/components/ui/switch/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import FileBrowserDialog from '$lib/components/dialogs/file-browser-dialog.svelte'; - import type { GitOpsSync, GitOpsSyncCreateDto, GitOpsSyncUpdateDto, GitRepository, BranchInfo } from '$lib/types/automation'; + import type { FileTreeNode, GitOpsSync, GitOpsSyncCreateDto, GitOpsSyncUpdateDto, GitRepository, BranchInfo } from '$lib/types/automation'; import { gitRepositoryService } from '$lib/services/git-repository-service'; import { settingsService } from '$lib/services/settings-service'; import { z } from 'zod/v4'; @@ -35,6 +35,10 @@ let isEditMode = $derived(!!syncToEdit); let showFileBrowser = $state(false); + let fileBrowserTarget = $state<'compose' | 'preDeployScript'>('compose'); + + const composeFileFilter = (file: FileTreeNode) => + file.type === 'file' && (file.name.endsWith('.yml') || file.name.endsWith('.yaml')); let selectedTargetType = $state('project'); const targetTypeOptions = [ @@ -56,7 +60,13 @@ maxSyncTotalSizeMb: z.coerce.number().int().nonnegative(), maxSyncBinarySizeMb: z.coerce.number().int().nonnegative(), autoSync: z.boolean().default(true), - syncInterval: z.number().min(1).default(5) + syncInterval: z.number().min(1).default(5), + preDeployScriptPath: z.string().default(''), + preDeployRunnerImage: z.string().default(''), + preDeployTimeoutSec: z.coerce.number().int().positive().default(60), + preDeployNetworkMode: z.string().default('none'), + preDeployEnv: z.string().default(''), + preDeployExtraMounts: z.string().default('') }); const bytesPerMegabyte = 1024 * 1024; @@ -94,7 +104,13 @@ ? bytesToMegabytesInternal(syncToEdit.maxSyncBinarySize, 0) : (settingsQuery.data?.gitSyncMaxBinarySizeMb ?? 0), autoSync: open && syncToEdit ? (syncToEdit.autoSync ?? true) : true, - syncInterval: open && syncToEdit ? (syncToEdit.syncInterval ?? 5) : 5 + syncInterval: open && syncToEdit ? (syncToEdit.syncInterval ?? 5) : 5, + preDeployScriptPath: open && syncToEdit ? (syncToEdit.preDeployScriptPath ?? '') : '', + preDeployRunnerImage: open && syncToEdit ? (syncToEdit.preDeployRunnerImage ?? '') : '', + preDeployTimeoutSec: open && syncToEdit ? (syncToEdit.preDeployTimeoutSec ?? 60) : 60, + preDeployNetworkMode: open && syncToEdit ? (syncToEdit.preDeployNetworkMode ?? 'none') : 'none', + preDeployEnv: open && syncToEdit ? (syncToEdit.preDeployEnv ?? '') : '', + preDeployExtraMounts: open && syncToEdit ? (syncToEdit.preDeployExtraMounts ?? '') : '' }); let { inputs, ...form } = $derived(createForm(formSchema, formData)); @@ -163,7 +179,13 @@ maxSyncTotalSize: megabytesToBytesInternal(data.maxSyncTotalSizeMb), maxSyncBinarySize: megabytesToBytesInternal(data.maxSyncBinarySizeMb), autoSync: data.autoSync, - syncInterval: data.syncInterval + syncInterval: data.syncInterval, + preDeployScriptPath: data.preDeployScriptPath.trim(), + preDeployRunnerImage: data.preDeployRunnerImage.trim(), + preDeployTimeoutSec: data.preDeployTimeoutSec, + preDeployNetworkMode: data.preDeployNetworkMode.trim(), + preDeployEnv: data.preDeployEnv.trim(), + preDeployExtraMounts: data.preDeployExtraMounts.trim() }; onSubmit({ sync: payload, isEditMode }); @@ -285,7 +307,10 @@ type="button" variant="outline" size="icon" - onclick={() => (showFileBrowser = true)} + onclick={() => { + fileBrowserTarget = 'compose'; + showFileBrowser = true; + }} disabled={!selectedRepository?.value || !$inputs.branch.value} title={m.git_sync_browse_files_title()} > @@ -380,6 +405,109 @@ + + + + {m.git_sync_pre_deploy_title()} + + + + +
+ + + + {m.git_sync_pre_deploy_acknowledgement()} + + + +

{m.git_sync_pre_deploy_description()}

+ +
+ +
+
+ +
+ +
+

{m.git_sync_pre_deploy_script_path_help()}

+ {#if $inputs.preDeployScriptPath.error} +

{$inputs.preDeployScriptPath.error}

+ {/if} +
+ + + +
+ + +
+ + + + +
+
+
+ @@ -412,11 +540,38 @@ {/snippet} +{#snippet composeBadge(file: FileTreeNode)} + {#if composeFileFilter(file)} + + {m.git_sync_browse_compose_label()} + + {/if} +{/snippet} + +{#snippet composeFooterHint()} +

{m.git_sync_browse_hint()}

+{/snippet} + { - $inputs.composePath.value = path; + if (fileBrowserTarget === 'preDeployScript') { + $inputs.preDeployScriptPath.value = path; + } else { + $inputs.composePath.value = path; + } }} /> diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index 3d66ad258f..a7ca58f9c8 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -19,6 +19,7 @@ disabled = false, type = 'text', rows = 3, + inputClass = '', children, autocomplete = 'off', ...restProps @@ -32,6 +33,9 @@ disabled?: boolean; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date' | 'switch' | 'textarea'; rows?: number; + // Forwarded to the inner Input/Textarea so callers can apply + // content-specific styling (e.g. font-mono for code-like values). + inputClass?: string; children?: Snippet; autocomplete?: HTMLInputElement['autocomplete']; } = $props(); @@ -53,11 +57,11 @@ {#if type === 'switch'} {:else if type === 'textarea'} -