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 diff --git a/backend/api/handlers/gitops_syncs.go b/backend/api/handlers/gitops_syncs.go index b75e6fa8d7..2fbafd6bba 100644 --- a/backend/api/handlers/gitops_syncs.go +++ b/backend/api/handlers/gitops_syncs.go @@ -4,6 +4,7 @@ import ( "context" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/v2/api/middleware" "github.com/getarcaneapp/arcane/backend/v2/internal/common" "github.com/getarcaneapp/arcane/backend/v2/internal/models" "github.com/getarcaneapp/arcane/backend/v2/internal/services" @@ -135,6 +136,25 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) registerGitOpsSecuredInternal(api, "browseGitOpsSyncFiles", "GET", "/environments/{id}/gitops-syncs/{syncId}/files", "Browse GitOps sync files", "Browse files in the synced repository", authz.PermGitOpsRead, h.BrowseFiles) } +// requireLifecyclePermissionInternal rejects callers lacking gitops:lifecycle +// for the target environment when a create/update request configures the +// pre-deploy lifecycle hook. Configuring the hook lets the caller run an +// arbitrary container — with host bind mounts, env, and network access — on +// every sync, so it is gated behind its own permission (seeded only into the +// Admin built-in role) rather than the broader gitops:create / gitops:update +// permissions that non-admin roles such as Editor hold. Whether a request +// touches the hook is decided by the request type itself +// (gitops.*SyncRequest.HasPreDeployConfig) so the field set has a single owner. +func requireLifecyclePermissionInternal(ctx context.Context, environmentID string, lifecycleRequested bool) error { + if !lifecycleRequested { + return nil + } + if ps, _ := humamw.PermissionsFromContext(ctx); ps.Allows(authz.PermGitOpsLifecycle, environmentID) { + return nil + } + return huma.Error403Forbidden("configuring a pre-deploy lifecycle hook requires the " + authz.PermGitOpsLifecycle + " permission") +} + // ============================================================================ // Handler Methods // ============================================================================ @@ -168,6 +188,10 @@ func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsS return nil, huma.Error500InternalServerError("service not available") } + if err := requireLifecyclePermissionInternal(ctx, input.EnvironmentID, input.Body.HasPreDeployConfig()); err != nil { + return nil, err + } + actor := currentActorInternal(ctx) sync, err := h.syncService.CreateSync(ctx, input.EnvironmentID, input.Body, actor) @@ -239,6 +263,10 @@ func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsS return nil, huma.Error500InternalServerError("service not available") } + if err := requireLifecyclePermissionInternal(ctx, input.EnvironmentID, input.Body.HasPreDeployConfig()); err != nil { + return nil, err + } + actor := currentActorInternal(ctx) sync, err := h.syncService.UpdateSync(ctx, input.EnvironmentID, input.SyncID, input.Body, actor) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 91392c8fbb..451ecfd66b 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -1577,7 +1577,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 { @@ -1593,7 +1593,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 { @@ -1651,6 +1651,31 @@ func (e *GitOpsSyncMappingError) Error() string { return "Failed to map GitOps sync" } +// RedeployAfterSyncFailedError is returned by the internal GitOps 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 { + Err error +} + +func (e *RedeployAfterSyncFailedError) Error() string { + if e == nil || e.Err == nil { + return "redeploy failed" + } + + return "redeploy failed: " + e.Err.Error() +} + +func (e *RedeployAfterSyncFailedError) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + type VulnerabilityScanError struct { Err error } diff --git a/backend/internal/common/errors_test.go b/backend/internal/common/errors_test.go new file mode 100644 index 0000000000..92450b4d0f --- /dev/null +++ b/backend/internal/common/errors_test.go @@ -0,0 +1,44 @@ +package common + +import ( + "errors" + "io" + "testing" +) + +func TestRedeployAfterSyncFailedErrorZeroValue(t *testing.T) { + err := &RedeployAfterSyncFailedError{} + + if got := err.Error(); got != "redeploy failed" { + t.Fatalf("Error() = %q, want %q", got, "redeploy failed") + } + + if errors.Is(err, io.EOF) { + t.Fatal("errors.Is matched an unrelated error") + } +} + +func TestRedeployAfterSyncFailedErrorNilReceiver(t *testing.T) { + var err *RedeployAfterSyncFailedError + + if got := err.Error(); got != "redeploy failed" { + t.Fatalf("Error() = %q, want %q", got, "redeploy failed") + } + + if got := err.Unwrap(); got != nil { + t.Fatalf("Unwrap() = %v, want nil", got) + } +} + +func TestRedeployAfterSyncFailedErrorWrapsErr(t *testing.T) { + cause := io.EOF + err := &RedeployAfterSyncFailedError{Err: cause} + + if got := err.Error(); got != "redeploy failed: EOF" { + t.Fatalf("Error() = %q, want %q", got, "redeploy failed: EOF") + } + + if !errors.Is(err, cause) { + t.Fatal("errors.Is did not match wrapped error") + } +} diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index d11d610a4b..0b4634cb62 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -305,6 +305,9 @@ var expectedSettingOverrideKeys = []string{ "httpClientTimeout", "iconCatalog", "keyboardShortcutsEnabled", + "lifecycleDefaultRunnerImage", + "lifecycleEnabled", + "lifecycleMaxTimeoutSec", "maxImageUploadSize", "mobileNavigationMode", "mobileNavigationShowLabels", diff --git a/backend/internal/di/providers.go b/backend/internal/di/providers.go index b075aeda65..af3c6b7141 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 @@ -114,8 +115,8 @@ func provideContainerRegistryServiceInternal(db *database.DB, docker *services.D }, kv) } -func provideProjectServiceInternal(db *database.DB, settings *services.SettingsService, event *services.EventService, image *services.ImageService, docker *services.DockerClientService, build *services.BuildService, kv *services.KVService, environment *services.EnvironmentService, cfg *config.Config) *services.ProjectService { - return services.NewProjectService(db, settings, event, image, docker, build, cfg). +func provideProjectServiceInternal(db *database.DB, settings *services.SettingsService, event *services.EventService, image *services.ImageService, docker *services.DockerClientService, build *services.BuildService, lifecycle *services.LifecycleService, kv *services.KVService, environment *services.EnvironmentService, cfg *config.Config) *services.ProjectService { + return services.NewProjectService(db, settings, event, image, docker, build, lifecycle, cfg). WithKVService(kv). WithRegistryCredentialsProvider(environment.GetEnabledRegistryCredentials) } diff --git a/backend/internal/di/wire.go b/backend/internal/di/wire.go index 666baba17b..ef68687a1b 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, provideProjectServiceInternal, services.NewContainerService, services.NewDashboardService, diff --git a/backend/internal/di/wire_gen.go b/backend/internal/di/wire_gen.go index a8f966e200..fd0aae858d 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 := provideProjectServiceInternal(db, settingsService, eventService, imageService, dockerClientService, buildService, kvService, environmentService, cfg) + lifecycleService := services.NewLifecycleService(db, settingsService, eventService, dockerClientService) + projectService := provideProjectServiceInternal(db, settingsService, eventService, imageService, dockerClientService, buildService, lifecycleService, kvService, environmentService, 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, @@ -166,7 +168,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, provideProjectServiceInternal, 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, provideProjectServiceInternal, 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 26bfa78d64..e9833b4d49 100644 --- a/backend/internal/models/event.go +++ b/backend/internal/models/event.go @@ -94,6 +94,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 04ced6d61f..b6c622e1ce 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -144,6 +144,9 @@ type Settings struct { TrivyServerUrl SettingVariable `key:"trivyServerUrl,envOverride" meta:"label=Trivy Server URL;type=text;keywords=trivy,server,url,remote,scanner,vulnerability,security;category=security;description=URL of the remote Trivy server (e.g. http://trivy.example.com:4954). Used when client/server mode is enabled."` TrivyServerToken SettingVariable `key:"trivyServerToken,envOverride,sensitive" meta:"label=Trivy Server Token;type=password;keywords=trivy,server,token,auth,remote,scanner,security;category=security;description=Optional authentication token sent to the remote Trivy server. Leave empty if the server requires no token."` TrivyIgnoreUnfixed SettingVariable `key:"trivyIgnoreUnfixed,envOverride" meta:"label=Only Report Fixable Vulnerabilities;type=boolean;keywords=trivy,fixable,unfixed,ignore,vulnerability,security,noise,fixes;category=security;description=Only report vulnerabilities that have a known fix available. Reduces noise from vulnerabilities you cannot act on."` + 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"` + LifecycleDefaultRunnerImage SettingVariable `key:"lifecycleDefaultRunnerImage,envOverride" meta:"label=Lifecycle Hook Runner Image;type=text;keywords=lifecycle,hook,runner,image,container,pre-deploy,script,gitops;category=security;description=Default container image used to run GitOps pre-deploy lifecycle scripts when a sync does not override it"` + 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 f198676463..5b660551d8 100644 --- a/backend/internal/services/dashboard_service_test.go +++ b/backend/internal/services/dashboard_service_test.go @@ -156,7 +156,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 ba3f291677..d8e015513d 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" "sync" @@ -105,6 +106,226 @@ 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 { + targetType *string + 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 + targetTypeChanging := in.targetType != nil && strings.TrimSpace(*in.targetType) != resolveLifecycleEffectiveTargetTypeInternal(current, nil) + effectiveScriptPath := resolveLifecycleEffectiveStringInternal(currentStringInternal(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 && !targetTypeChanging { + return nil + } + if !lifecycleFieldSet && 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 err := s.validateLifecycleScriptConfigInternal(ctx, current, in, effectiveScriptPath); err != nil { + return err + } + } + + 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 +} + +func (s *GitOpsSyncService) validateLifecycleScriptConfigInternal(ctx context.Context, current *models.GitOpsSync, in lifecycleConfigInputInternal, scriptPath string) error { + if resolveLifecycleEffectiveTargetTypeInternal(current, in.targetType) == "swarm_stack" { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy lifecycle hooks are only supported for project syncs."} + } + + if len(scriptPath) > 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(scriptPath)) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be relative to the project directory."} + } + cleaned := filepath.ToSlash(filepath.Clean(scriptPath)) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must not escape the project directory."} + } + + effectiveRunnerImage := resolveLifecycleEffectiveStringInternal(currentStringInternal(current, func(c *models.GitOpsSync) *string { return c.PreDeployRunnerImage }), in.runnerImage) + defaultRunnerImage := strings.TrimSpace(s.settingsService.GetStringSetting(ctx, "lifecycleDefaultRunnerImage", "")) + if effectiveRunnerImage == "" && defaultRunnerImage == "" { + 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."} + } + + 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 currentStringInternal(sync *models.GitOpsSync, accessor func(*models.GitOpsSync) *string) string { + if sync == nil { + return "" + } + if p := accessor(sync); p != nil { + return *p + } + return "" +} + +func resolveLifecycleEffectiveTargetTypeInternal(current *models.GitOpsSync, update *string) string { + if update != nil && strings.TrimSpace(*update) != "" { + return strings.TrimSpace(*update) + } + if current != nil && strings.TrimSpace(current.TargetType) != "" { + return strings.TrimSpace(current.TargetType) + } + return "project" +} + +// 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 + } else { + sync.PreDeployTimeoutSec = lifecycleDefaultTimeoutSec + } + 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 @@ -449,7 +670,24 @@ 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{ + targetType: &req.TargetType, + 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) } @@ -547,6 +785,21 @@ func (s *GitOpsSyncService) UpdateSync(ctx context.Context, environmentID, id st updates["max_sync_binary_size"] = *req.MaxSyncBinarySize } + lifecycleCfg := lifecycleConfigInputInternal{ + targetType: req.TargetType, + 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) @@ -753,7 +1006,14 @@ 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 { + // redeployIfRunningAfterSync only ever returns *common.RedeployAfterSyncFailedError; + // AsType remains as a defensive guard against future contract drift. + if typed, ok := errors.AsType[*common.RedeployAfterSyncFailedError](redeployErr); ok { + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, typed, actor, result) + } + return result, redeployErr + } } s.updateSyncStatusWithFiles(ctx, id, "success", "", source.commitHash, syncedFiles) @@ -771,6 +1031,14 @@ func (s *GitOpsSyncService) performSingleFileSyncInternal(ctx context.Context, s project, err := s.getOrCreateProjectInternal(ctx, sync, id, source.composeContent, source.envContent, result, actor) if err != nil { + // err may be a *common.RedeployAfterSyncFailedError (from updateProjectForSyncInternal) + // or a plain failSync error (from CreateProject / ApplyGitSyncProjectFiles). + // Only the typed case warrants the redeploy-failure marker; the rest just + // surface through the caller. + if redeployErr, ok := errors.AsType[*common.RedeployAfterSyncFailedError](err); ok { + syncedFiles := []string{filepath.Base(sync.ComposePath)} + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, redeployErr, actor, result) + } return result, err } @@ -855,20 +1123,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 +// *common.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, nil); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "syncMode", syncMode, "error", err, "projectId", project.ID) + return &common.RedeployAfterSyncFailedError{Err: err} } + return nil } // logSyncSuccess records the Git sync completion event once the filesystem and @@ -1071,6 +1343,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 *common.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) disableAutoSyncForBrokenBindingInternal(ctx context.Context, sync *models.GitOpsSync) { if sync == nil || sync.ID == "" { return @@ -1173,13 +1459,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 *common.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, nil); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "error", err, "projectId", project.ID) + return &common.RedeployAfterSyncFailedError{Err: err} } } } @@ -1251,6 +1540,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 7c656c1516..391b1e5c6d 100644 --- a/backend/internal/services/gitops_sync_service_test.go +++ b/backend/internal/services/gitops_sync_service_test.go @@ -14,7 +14,7 @@ import ( "github.com/getarcaneapp/arcane/backend/v2/internal/models" git "github.com/getarcaneapp/arcane/backend/v2/pkg/gitutil" "github.com/getarcaneapp/arcane/backend/v2/pkg/projects" - gitopstypes "github.com/getarcaneapp/arcane/types/v2/gitops" + "github.com/getarcaneapp/arcane/types/v2/gitops" schedulertypes "github.com/getarcaneapp/arcane/types/v2/scheduler" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,7 +35,7 @@ func setupGitOpsSyncDirectoryTestService(t *testing.T) (*GitOpsSyncService, *dat require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) eventService := NewEventService(db, config.Load(), nil) - projectService := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + projectService := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) return NewGitOpsSyncService(db, nil, projectService, nil, eventService, settingsService), db, projectsDir } @@ -64,6 +64,23 @@ func writeFileInternal(t *testing.T, rootDir, relativePath string, content []byt require.NoError(t, os.WriteFile(targetPath, content, 0o644)) } +func TestApplyLifecycleFieldsToSyncInternal_DefaultsPreDeployTimeout(t *testing.T) { + var sync models.GitOpsSync + + applyLifecycleFieldsToSyncInternal(&sync, lifecycleConfigInputInternal{}) + + require.Equal(t, lifecycleDefaultTimeoutSec, sync.PreDeployTimeoutSec) +} + +func TestApplyLifecycleFieldsToSyncInternal_UsesExplicitPreDeployTimeout(t *testing.T) { + timeoutSec := 90 + var sync models.GitOpsSync + + applyLifecycleFieldsToSyncInternal(&sync, lifecycleConfigInputInternal{timeoutSec: &timeoutSec}) + + require.Equal(t, timeoutSec, sync.PreDeployTimeoutSec) +} + func TestGitOpsSyncService_GetSyncByID_ReturnsNotFoundError(t *testing.T) { ctx := context.Background() svc, _, _ := setupGitOpsSyncDirectoryTestService(t) @@ -720,7 +737,7 @@ func TestGitOpsSyncService_GetOrCreateProjectInternal_FailsWhenBoundProjectMissi } require.NoError(t, db.Create(sync).Error) - result := &gitopstypes.SyncResult{} + result := &gitops.SyncResult{} project, err := svc.getOrCreateProjectInternal(ctx, sync, sync.ID, "services:\n app:\n image: nginx:alpine\n", nil, result, models.User{}) require.Error(t, err) var bindingErr *common.GitOpsSyncProjectBindingBrokenError @@ -881,3 +898,294 @@ 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) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleDefaultRunnerImage", " ")) + 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_AcceptsScriptWithDefaultRunnerImageOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleDefaultRunnerImage", "alpine:latest")) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + targetType: strPtr("project"), + scriptPath: strPtr("scripts/deploy.sh"), + syncDirectory: boolPtr(true), + })) +} + +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{ + targetType: strPtr("project"), + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(true), + })) +} + +func TestValidateLifecycleConfig_RejectsLifecycleHookForSwarmStack(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + targetType: strPtr("swarm_stack"), + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(true), + }) + 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) + require.Contains(t, err.Error(), "project syncs") +} + +func TestValidateLifecycleConfig_RejectsSwarmTargetChangeWithExistingLifecycleHook(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{TargetType: "project", SyncDirectory: true} + existing.PreDeployScriptPath = strPtr("scripts/deploy.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + err := svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + targetType: strPtr("swarm_stack"), + }) + 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) + require.Contains(t, err.Error(), "project syncs") +} + +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 := &common.RedeployAfterSyncFailedError{Err: 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[*common.RedeployAfterSyncFailedError](err) + require.True(t, ok) + require.Equal(t, cause, typed.Err) +} + +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 := &common.RedeployAfterSyncFailedError{Err: 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..db8b77bd01 --- /dev/null +++ b/backend/internal/services/lifecycle_service.go @@ -0,0 +1,735 @@ +package services + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "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/v2/internal/database" + "github.com/getarcaneapp/arcane/backend/v2/internal/models" + dockerutils "github.com/getarcaneapp/arcane/backend/v2/pkg/dockerutil" + "github.com/getarcaneapp/arcane/backend/v2/pkg/libarcane" + "github.com/getarcaneapp/arcane/backend/v2/pkg/libarcane/timeouts" + "github.com/getarcaneapp/arcane/backend/v2/pkg/projects" + "github.com/getarcaneapp/arcane/backend/v2/pkg/utils" + buildapi "go.getarcane.app/builds/api" +) + +// 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, actor models.User) 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, actor) +} + +func (s *LifecycleService) executePreDeployInternal(ctx context.Context, project *models.Project, sync *models.GitOpsSync, actor models.User) error { + runnerImage := s.resolveRunnerImageInternal(ctx, sync) + if runnerImage == "" { + return fmt.Errorf("pre-deploy script %q is configured but no runner image is set on the GitOps sync or lifecycleDefaultRunnerImage setting", *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) + // stdoutBuf/stderrBuf already enforce lifecycleMaxOutputBytes each with a + // proper "..." marker, so the combined string is already + // bounded and we don't slice again here — a second byte-boundary cut + // could land mid-UTF-8 codepoint and produce garbled output. + persistedOutput := combineLifecycleOutputInternal(stdoutContent, stderrContent) + s.persistLastRunInternal(ctx, sync.ID, status, persistedOutput, start) + s.emitLifecycleEventInternal(ctx, project, sync, runnerImage, status, exitCode, durationMs, runErr, actor) + + if runErr != nil { + return runErr + } + if exitCode != 0 { + return fmt.Errorf("pre-deploy script exited with status %d", exitCode) + } + return nil +} + +func (s *LifecycleService) resolveRunnerImageInternal(ctx context.Context, sync *models.GitOpsSync) string { + if sync != nil { + runnerImage := strings.TrimSpace(utils.DerefString(sync.PreDeployRunnerImage)) + if runnerImage != "" { + return runnerImage + } + } + return strings.TrimSpace(s.settingsService.GetStringSetting(ctx, "lifecycleDefaultRunnerImage", "alpine:latest")) +} + +// 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.GetSettingsConfig().DockerAPITimeout.AsInt() + 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 := buildapi.NewLogCapture(lifecycleMaxOutputBytes) + stderrBuf := buildapi.NewLogCapture(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 markTruncatedInternal(stdoutBuf), markTruncatedInternal(stderrBuf), 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 markTruncatedInternal(stdoutBuf), markTruncatedInternal(stderrBuf), 0, fmt.Errorf("pre-deploy script timed out after %s: %w", timeout, context.DeadlineExceeded) + } + if errors.Is(waitErr, context.Canceled) { + return markTruncatedInternal(stdoutBuf), markTruncatedInternal(stderrBuf), 0, fmt.Errorf("pre-deploy script cancelled: %w", waitErr) + } + return markTruncatedInternal(stdoutBuf), markTruncatedInternal(stderrBuf), 0, fmt.Errorf("lifecycle container wait failed: %w", waitErr) + } + + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + + return markTruncatedInternal(stdoutBuf), markTruncatedInternal(stderrBuf), 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.GetSettingsConfig().DockerImagePullTimeout.AsInt() + 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, + runnerImage string, + status string, + exitCode int64, + durationMs int64, + runErr error, + actor models.User, +) { + severity := models.EventSeveritySuccess + title := "Pre-deploy lifecycle hook succeeded: " + project.Name + description := fmt.Sprintf("Script %s exited with code %d in %dms", utils.DerefString(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": utils.DerefString(sync.PreDeployScriptPath), + "runnerImage": runnerImage, + "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), + UserID: new(actor.ID), + Username: new(actor.Username), + 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 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() +} + +// truncatableCaptureInternal is satisfied by the capped build log capture. It is +// declared locally so the lifecycle service depends on the shared capped-output +// writer's behaviour, not on build-domain types. +type truncatableCaptureInternal interface { + String() string + Truncated() bool +} + +// markTruncatedInternal returns the captured output, appending a truncation +// marker when the capture hit its byte cap, matching what the GitOps UI shows. +func markTruncatedInternal(c truncatableCaptureInternal) string { + out := c.String() + if c.Truncated() { + out += "\n..." + } + return out +} diff --git a/backend/internal/services/lifecycle_service_test.go b/backend/internal/services/lifecycle_service_test.go new file mode 100644 index 0000000000..7063599589 --- /dev/null +++ b/backend/internal/services/lifecycle_service_test.go @@ -0,0 +1,421 @@ +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "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/v2/internal/database" + "github.com/getarcaneapp/arcane/backend/v2/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 TestRunPreDeploy_NilProject(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + require.NoError(t, svc.RunPreDeploy(context.Background(), nil, models.User{})) +} + +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, models.User{})) +} + +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, models.User{})) +} + +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, models.User{})) +} + +func TestRunPreDeploy_MissingRunnerImage(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleDefaultRunnerImage", " ")) + + 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, models.User{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "runner image") +} + +func TestResolveRunnerImage_UsesSettingDefault(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleDefaultRunnerImage", "alpine:latest")) + + assert.Equal(t, "alpine:latest", svc.resolveRunnerImageInternal(context.Background(), &models.GitOpsSync{})) + + override := "debian:stable-slim" + assert.Equal(t, override, svc.resolveRunnerImageInternal(context.Background(), &models.GitOpsSync{ + PreDeployRunnerImage: &override, + })) +} + +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, models.User{}) + 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 9be9baedd9..0448460139 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -51,6 +51,7 @@ type ProjectService struct { imageService *ImageService dockerService *DockerClientService buildService *BuildService + lifecycleService *LifecycleService kvService *KVService config *config.Config registryCredentialsProvider registryCredentialsProviderInternal @@ -69,16 +70,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](), } } @@ -2124,15 +2126,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, user); 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 e713af2f65..f2c4b1976c 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -89,7 +89,7 @@ func setupProjectDestroyTestServiceInternal(t *testing.T) (*ProjectService, *dat require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) eventService := NewEventService(db, config.Load(), nil) - return NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()), db, projectsDir + return NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()), db, projectsDir } func newProjectImagePullServer(t *testing.T, inspectByRef map[string]dockertypesimage.InspectResponse) *httptest.Server { @@ -200,7 +200,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{ @@ -317,7 +317,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{ @@ -429,7 +429,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"}, @@ -445,7 +445,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"}, @@ -461,7 +461,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"}, @@ -477,7 +477,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"}, @@ -514,7 +514,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"}, @@ -655,7 +655,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) @@ -751,7 +751,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", @@ -803,7 +803,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", @@ -866,7 +866,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", @@ -970,7 +970,7 @@ func TestProjectService_ComposePullSelectedServicesInternal_LeavesRecordsWhenPul dockerService := &DockerClientService{client: newTestDockerClient(t, failingServer)} 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", @@ -1060,7 +1060,7 @@ func TestProjectService_UpdateProjectServicesHardFailsWhenPullFailsInternal(t *t return errors.New("compose up should not run") } - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, 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") @@ -1133,7 +1133,7 @@ func TestProjectService_UpdateProjectServicesForcesRecreateInternal(t *testing.T return errors.New("compose up failed after assertion") } - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) require.Error(t, err) assert.True(t, upCalled) @@ -1203,7 +1203,7 @@ func TestProjectService_UpdateProject_RenameFailsWhenVolumeMigrationPreparationF t.Setenv("DOCKER_HOST", dockerHostFromProjectRuntimeServerURLInternal(t, dockerServer.URL)) dockerService := &DockerClientService{client: newTestDockerClient(t, dockerServer)} - svc := NewProjectService(db, settingsService, eventService, nil, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, dockerService, nil, nil, config.Load()) originalDirName := "Foo" originalPath := createComposeProjectDir(t, projectsDir, originalDirName) @@ -1244,7 +1244,7 @@ func TestProjectService_ApplyProjectUpdateWithRenameJournal_AppliesVolumeMigrati 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()) migration := &fakeProjectVolumeRenameMigrationInternal{} @@ -1315,7 +1315,7 @@ func TestProjectService_PrepareProjectRenameVolumeMigrationForUpdate_UsesCompose t.Cleanup(dockerServer.Close) dockerService := &DockerClientService{client: newTestDockerClient(t, dockerServer)} - svc := NewProjectService(db, settingsService, nil, nil, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, dockerService, nil, nil, config.Load()) projectPath := filepath.Join(projectsDir, "nginx") require.NoError(t, os.MkdirAll(projectPath, 0o755)) @@ -1406,7 +1406,7 @@ func TestProjectService_ApplyProjectUpdateWithRenameJournal_RollsBackVolumeMigra 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()) migration := &fakeProjectVolumeRenameMigrationInternal{} @@ -1455,7 +1455,7 @@ func TestProjectService_ApplyProjectUpdateWithRenameJournal_SucceedsCommittedRen 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()) migration := &fakeProjectVolumeRenameMigrationInternal{ commitErr: errors.New("source cleanup failed"), @@ -1507,7 +1507,7 @@ func TestProjectService_UpdateProject_ClearsJournalForNonRenameWhenRecoveryDocke eventService := NewEventService(db, nil, nil) kvService := NewKVService(db) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()).WithKVService(kvService) oldDir := "nginx" projectPath := createComposeProjectDir(t, projectsDir, oldDir) @@ -1572,7 +1572,7 @@ func TestProjectService_UpdateProject_AllowsRenameAfterJournalRecoveryDockerUnav eventService := NewEventService(db, nil, nil) kvService := NewKVService(db) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()).WithKVService(kvService) oldDir := "nginx" projectPath := createComposeProjectDir(t, projectsDir, oldDir) @@ -1632,7 +1632,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()) configureProjectRuntimeDockerInternal(t, nil) originalDirName := "Foo" @@ -1680,7 +1680,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()) configureProjectRuntimeDockerInternal(t, nil) originalDirName := "Foo" @@ -1726,7 +1726,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) @@ -1770,7 +1770,7 @@ func TestProjectService_UpdateProject_RenameRejectsStaleStoppedWhenRuntimeIsRunn require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) 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()) configureProjectRuntimeDockerInternal(t, []container.Summary{ { ID: "app-container", @@ -1827,7 +1827,7 @@ func TestProjectService_UpdateProject_RenameResolvesUnknownStoppedStatusBeforeVo require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) 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()) server := newProjectRuntimeDockerServerInternal(t, nil) t.Setenv("DOCKER_HOST", dockerHostFromProjectRuntimeServerURLInternal(t, server.URL)) @@ -1884,7 +1884,7 @@ func TestProjectService_UpdateProject_RenameRejectsUnknownWhenRuntimeIsRunning(t require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) 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()) server := newProjectRuntimeDockerServerInternal(t, []container.Summary{ { @@ -1946,7 +1946,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) @@ -1988,7 +1988,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) @@ -2032,7 +2032,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) @@ -2096,7 +2096,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) @@ -2140,7 +2140,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: @@ -2176,7 +2176,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: @@ -2214,7 +2214,7 @@ func TestProjectService_CreateProject_WritesStagedProjectFiles(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()) includeContent := "services: {}\n" compose := `include: @@ -2269,7 +2269,7 @@ func TestProjectService_GetProjectDetails_UsesFileTreeMaxDepthForProjectFiles(t eventService := NewEventService(db, nil, nil) cfg := config.Load() - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, cfg) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, cfg) deepFolder := filepath.ToSlash(filepath.Join("level1", "level2", "level3", "level4", "level5")) project, err := svc.CreateProject(ctx, "deep-files", "services:\n app:\n image: nginx:alpine\n", nil, []projecttypes.ProjectFileDraft{ @@ -2311,7 +2311,7 @@ func TestProjectService_UpdateProject_AppliesStagedProjectFileChanges(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()) project, err := svc.CreateProject(ctx, "editable-files", "services:\n app:\n image: nginx:alpine\n", nil, nil, models.User{ BaseModel: models.BaseModel{ID: "u1"}, @@ -2353,7 +2353,7 @@ func TestProjectService_UpdateProject_RejectsStaleProjectFileRevision(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()) project, err := svc.CreateProject(ctx, "stale-files", "services:\n app:\n image: nginx:alpine\n", nil, nil, models.User{ BaseModel: models.BaseModel{ID: "u1"}, @@ -2391,7 +2391,7 @@ func TestProjectService_UpdateProject_RejectsStaleDeepProjectFileRevision(t *tes 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()) project, err := svc.CreateProject(ctx, "stale-deep-files", "services:\n app:\n image: nginx:alpine\n", nil, nil, models.User{ BaseModel: models.BaseModel{ID: "u1"}, @@ -2431,7 +2431,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) @@ -2474,7 +2474,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) @@ -2520,7 +2520,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) @@ -2566,7 +2566,7 @@ func TestProjectService_GetProjectFileContent_RejectsIntermediateSymlinkProjectF 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 := "project-file-intermediate-symlink" projectPath := filepath.Join(projectsDir, dirName) @@ -2605,7 +2605,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) @@ -2651,7 +2651,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) @@ -2697,7 +2697,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) @@ -2741,7 +2741,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) @@ -2789,7 +2789,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) @@ -2832,7 +2832,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) @@ -2878,7 +2878,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) @@ -2927,7 +2927,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) @@ -2978,7 +2978,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) @@ -3026,7 +3026,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) @@ -3075,7 +3075,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) @@ -3119,7 +3119,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) @@ -3166,7 +3166,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) @@ -3204,7 +3204,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) @@ -3331,7 +3331,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)) @@ -3430,7 +3430,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) @@ -3482,7 +3482,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) @@ -3506,7 +3506,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)) @@ -3737,7 +3737,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}, @@ -3789,7 +3789,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 @@ -3819,7 +3819,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)) @@ -3960,7 +3960,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{ @@ -4239,7 +4239,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) @@ -4281,7 +4281,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) @@ -4331,7 +4331,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) @@ -4357,7 +4357,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) @@ -4382,7 +4382,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) @@ -4408,7 +4408,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{ @@ -4442,7 +4442,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) @@ -4489,7 +4489,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{ @@ -4517,7 +4517,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) @@ -4538,7 +4538,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) @@ -4557,7 +4557,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) @@ -4599,7 +4599,7 @@ func TestProjectService_SyncProjectsFromFileSystem_PreservesProjectsWhenDirector }).Error) } - 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) @@ -4619,7 +4619,7 @@ func TestProjectService_SyncProjectsFromFileSystem_PreservesProjectWithAmbiguous 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) @@ -4658,7 +4658,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RemovesProjectsBeyondReducedS // Initial sync at the default scan depth discovers both the top-level and // the nested project, persisting them to the database. - defaultSvc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + defaultSvc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, defaultSvc.SyncProjectsFromFileSystem(ctx)) items, err := defaultSvc.ListAllProjects(ctx) @@ -4668,7 +4668,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RemovesProjectsBeyondReducedS // Lowering the scan depth must prune the nested project from the database on // the next sync, even though its compose file still exists on disk. t.Setenv("PROJECT_SCAN_MAX_DEPTH", "1") - depthLimitedSvc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + depthLimitedSvc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, depthLimitedSvc.SyncProjectsFromFileSystem(ctx)) items, err = depthLimitedSvc.ListAllProjects(ctx) @@ -4701,7 +4701,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) @@ -4736,7 +4736,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 @@ -4767,7 +4767,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{ @@ -4798,7 +4798,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) @@ -4828,7 +4828,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 @@ -4882,7 +4882,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) @@ -4932,7 +4932,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) @@ -4964,7 +4964,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"}, @@ -5108,7 +5108,7 @@ func TestProjectService_UpdateProject_RenameRollsBackWhenFileRevisionIsStale(t * require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) 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()) configureProjectRuntimeDockerInternal(t, nil) originalDirName := "Foo" @@ -5171,7 +5171,7 @@ func TestProjectService_RecoverProjectRenameJournals_RollsBackUncommittedDirecto require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5225,7 +5225,7 @@ func TestProjectService_RecoverProjectRenameJournals_StartedPhaseSkipsVolumeRoll require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5288,7 +5288,7 @@ func TestProjectService_RecoverProjectRenameJournals_RelocatesTargetWhenBothPath require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5351,7 +5351,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsStartedJournalWhenDir require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5414,7 +5414,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsPreservedTargetJourna t.Cleanup(server.Close) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5476,7 +5476,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsCommittedJournal(t *t require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5516,7 +5516,7 @@ func TestProjectService_FinalizeProjectRenameAfterCommit_ClearsJournalAfterSourc require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := &projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5557,7 +5557,7 @@ func TestProjectService_FinalizeProjectRenameAfterCommit_KeepsJournalWhenSourceC require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := &projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5636,7 +5636,7 @@ func TestProjectService_RecoverProjectRenameJournals_KeepsJournalWhenDirectoryRo kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5728,7 +5728,7 @@ func TestProjectService_RecoverProjectRenameJournals_CompletesCommittedVolumeJou kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5799,7 +5799,7 @@ func TestProjectService_RecoverProjectRenameJournals_RollsBackCommittedJournalWh kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5901,7 +5901,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsJournalAfterDBRestore kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -5978,7 +5978,7 @@ func TestProjectService_RecoverProjectRenameJournals_KeepsRollbackCleanupWhenDoc require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) cleanup := projectRenameRollbackCleanupInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6041,7 +6041,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsCommittedJournalWhenS kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6123,7 +6123,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsCommittedJournalAndCl kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6204,7 +6204,7 @@ func TestProjectService_RecoverProjectRenameJournals_MarksSourceCleanupPendingWh kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6287,7 +6287,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsSourceCleanupPendingJ kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6378,7 +6378,7 @@ func TestProjectService_RecoverProjectRenameJournals_RollsBackSourceCleanupPendi kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6466,7 +6466,7 @@ func TestProjectService_RecoverProjectRenameJournals_KeepsSourceCleanupPendingJo kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6532,7 +6532,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsStartedJournalWhenDir require.NoError(t, db.Create(project).Error) kvService := NewKVService(db) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6598,7 +6598,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsMissingPathJournalWhe kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6672,7 +6672,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsJournalWhenRollbackSo kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6749,7 +6749,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsJournalWhenRollbackTa kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", @@ -6830,7 +6830,7 @@ func TestProjectService_RecoverProjectRenameJournals_ClearsJournalWhenTargetPres kvService := NewKVService(db) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} - svc := NewProjectService(db, nil, nil, nil, dockerService, nil, config.Load()).WithKVService(kvService) + svc := NewProjectService(db, nil, nil, nil, dockerService, nil, nil, config.Load()).WithKVService(kvService) journal := projectRenameJournalInternal{ ProjectID: project.ID, OldName: "nginx", diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index ba7df4a97e..07c084c409 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -194,6 +194,9 @@ func DefaultSettingsConfig() *models.Settings { GitSyncMaxTotalSizeMb: models.SettingVariable{Value: "50"}, GitSyncMaxBinarySizeMb: models.SettingVariable{Value: "10"}, EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"}, + LifecycleEnabled: models.SettingVariable{Value: "false"}, + LifecycleDefaultRunnerImage: models.SettingVariable{Value: "alpine:latest"}, + LifecycleMaxTimeoutSec: models.SettingVariable{Value: "300"}, DockerAPITimeout: models.SettingVariable{Value: "30"}, DockerImagePullTimeout: models.SettingVariable{Value: "600"}, diff --git a/backend/internal/services/settings_service_test.go b/backend/internal/services/settings_service_test.go index 63e41d7e97..c60344a70e 100644 --- a/backend/internal/services/settings_service_test.go +++ b/backend/internal/services/settings_service_test.go @@ -43,7 +43,7 @@ func TestSettingsService_EnsureDefaultSettings_Idempotent(t *testing.T) { require.Equal(t, count1, count2) // Spot-check core and automation defaults exist with correct values - for _, key := range []string{"authLocalEnabled", "projectsDirectory", "followProjectSymlinks", "autoUpdateExcludedContainers", "vulnerabilityScanEnabled", "vulnerabilityScanInterval", "trivyImage", "trivyNetwork", "trivySecurityOpts", "trivyPrivileged", "trivyPreserveCacheOnVolumePrune", "trivyResourceLimitsEnabled", "trivyCpuLimit", "trivyMemoryLimitMb", "trivyConcurrentScanContainers", "gitSyncMaxFiles", "gitSyncMaxTotalSizeMb", "gitSyncMaxBinarySizeMb"} { + for _, key := range []string{"authLocalEnabled", "projectsDirectory", "followProjectSymlinks", "autoUpdateExcludedContainers", "vulnerabilityScanEnabled", "vulnerabilityScanInterval", "trivyImage", "trivyNetwork", "trivySecurityOpts", "trivyPrivileged", "trivyPreserveCacheOnVolumePrune", "trivyResourceLimitsEnabled", "trivyCpuLimit", "trivyMemoryLimitMb", "trivyConcurrentScanContainers", "gitSyncMaxFiles", "gitSyncMaxTotalSizeMb", "gitSyncMaxBinarySizeMb", "lifecycleDefaultRunnerImage"} { var sv models.SettingVariable err := svc.db.WithContext(ctx).Where("key = ?", key).First(&sv).Error require.NoErrorf(t, err, "missing default key %s", key) @@ -59,6 +59,8 @@ func TestSettingsService_EnsureDefaultSettings_Idempotent(t *testing.T) { require.Equal(t, "0 0 0 * * *", sv.Value) case "trivyImage": require.Equal(t, DefaultTrivyImage, sv.Value) + case "lifecycleDefaultRunnerImage": + require.Equal(t, "alpine:latest", sv.Value) case "trivyNetwork": require.Equal(t, "", sv.Value) case "trivySecurityOpts": diff --git a/backend/internal/services/updater_service_test.go b/backend/internal/services/updater_service_test.go index e3cefa0a8f..09f9983608 100644 --- a/backend/internal/services/updater_service_test.go +++ b/backend/internal/services/updater_service_test.go @@ -405,7 +405,7 @@ func TestUpdaterService_PullImageAdapterInternal(t *testing.T) { registrySvc := NewContainerRegistryService(db, nil, NewKVService(db)) imageSvc := NewImageService(db, dockerSvc, registrySvc, nil, nil, NewEventService(db, nil, nil)) envSvc := NewEnvironmentService(db, nil, nil, nil, nil, nil) - projectSvc := NewProjectService(db, nil, nil, nil, nil, nil, nil). + projectSvc := NewProjectService(db, nil, nil, nil, nil, nil, nil, nil). WithRegistryCredentialsProvider(envSvc.GetEnabledRegistryCredentials) svc := NewUpdaterService(db, nil, dockerSvc, projectSvc, nil, nil, nil, imageSvc, nil, nil, nil) var progress bytes.Buffer diff --git a/backend/pkg/authz/catalog.go b/backend/pkg/authz/catalog.go index 59e54596f6..7c74132369 100644 --- a/backend/pkg/authz/catalog.go +++ b/backend/pkg/authz/catalog.go @@ -173,6 +173,7 @@ var permissionCatalog = []PermissionCatalogResource{ {"update", PermGitOpsUpdate, "Update", ""}, {"delete", PermGitOpsDelete, "Delete", ""}, {"sync", PermGitOpsSync, "Trigger sync", ""}, + {"lifecycle", PermGitOpsLifecycle, "Configure pre-deploy hooks", "Create or modify a sync's pre-deploy lifecycle hook, which runs an arbitrary container with host bind mounts and env on every sync"}, }}, {"webhooks", "Webhooks", PermissionScopeEnv, []PermissionCatalogAction{ {"list", PermWebhooksList, "List", ""}, diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go index 47779b691d..ff975a4696 100644 --- a/backend/pkg/authz/permissions.go +++ b/backend/pkg/authz/permissions.go @@ -159,6 +159,13 @@ const ( PermGitOpsUpdate = "gitops:update" PermGitOpsDelete = "gitops:delete" PermGitOpsSync = "gitops:sync" + // PermGitOpsLifecycle gates configuring a sync's pre-deploy lifecycle hook + // (script + runner image + env + host bind mounts + network mode), which is + // effectively arbitrary code execution on the host. It is intentionally + // separate from gitops:create / gitops:update so that holding those broader + // permissions (e.g. the built-in Editor role) does not by itself grant the + // ability to author hooks; seeded only into the Admin built-in role. + PermGitOpsLifecycle = "gitops:lifecycle" PermWebhooksList = "webhooks:list" PermWebhooksCreate = "webhooks:create" diff --git a/backend/pkg/dockerutil/mount_utils.go b/backend/pkg/dockerutil/mount_utils.go index e30ea8c6a9..84b5655f85 100644 --- a/backend/pkg/dockerutil/mount_utils.go +++ b/backend/pkg/dockerutil/mount_utils.go @@ -1,12 +1,144 @@ package docker import ( + "context" + "os" "strings" + "github.com/getarcaneapp/arcane/backend/v2/pkg/libarcane" containertypes "github.com/moby/moby/api/types/container" mounttypes "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" ) +func getCurrentContainerInspectTargetInternal(currentContainerID func() (string, error), hostname func() (string, error)) (string, error) { + if currentContainerID != nil { + if containerID, err := currentContainerID(); err == nil { + if containerID = strings.TrimSpace(containerID); containerID != "" { + return containerID, nil + } + } + } + + if hostname == nil { + hostname = os.Hostname + } + + value, err := hostname() + if err != nil { + return "", err + } + + 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 !pathHasPrefixInternal(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 + } +} + +// pathHasPrefixInternal reports whether containerPath is at or under prefix, +// treating both as POSIX-style paths. Avoids false positives like +// "/app/datax" matching "/app/data". +func pathHasPrefixInternal(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 2d0cff698e..6ebd015c44 100644 --- a/backend/pkg/dockerutil/mount_utils_test.go +++ b/backend/pkg/dockerutil/mount_utils_test.go @@ -81,3 +81,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 915d052ff4..60c3f488d1 100644 --- a/backend/pkg/gitutil/git.go +++ b/backend/pkg/gitutil/git.go @@ -597,6 +597,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 @@ -728,11 +733,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 725d6abe63..8fed146943 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 275a07858e..9e20297651 100644 --- a/backend/pkg/projects/fs_writer.go +++ b/backend/pkg/projects/fs_writer.go @@ -218,6 +218,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. @@ -272,10 +275,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 971047c5b0..af5895309e 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 87827397e3..5f34eed4d7 100644 --- a/backend/pkg/projects/load.go +++ b/backend/pkg/projects/load.go @@ -246,6 +246,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/062_add_gitops_sync_pre_deploy_hook.sql b/backend/resources/migrations/postgres/062_add_gitops_sync_pre_deploy_hook.sql new file mode 100644 index 0000000000..57c445a3b9 --- /dev/null +++ b/backend/resources/migrations/postgres/062_add_gitops_sync_pre_deploy_hook.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- 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; + +-- +goose Down +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/062_add_gitops_sync_pre_deploy_hook.sql b/backend/resources/migrations/sqlite/062_add_gitops_sync_pre_deploy_hook.sql new file mode 100644 index 0000000000..abc6fc3078 --- /dev/null +++ b/backend/resources/migrations/sqlite/062_add_gitops_sync_pre_deploy_hook.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- 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; + +-- +goose Down +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/frontend/messages/en.json b/frontend/messages/en.json index c9dbbd12f3..db3f73e915 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1578,6 +1578,8 @@ "security_password_policy_basic_tooltip": "Minimum 8 characters.", "security_password_policy_standard_tooltip": "10+ chars with upper, lower, and a number.", "security_password_policy_strong_tooltip": "12+ chars with upper, lower, number, and symbol.", + "security_trivy_tab": "Trivy", + "security_lifecycle_tab": "Lifecycle", "security_vulnerability_scanning_heading": "Vulnerability Scanning", "security_trivy_image_label": "Arcane Tools Image", "security_trivy_image_description": "Container image used to run Trivy vulnerability scans.", @@ -1619,6 +1621,16 @@ "security_trivy_server_token_label": "Trivy Server Token", "security_trivy_server_token_description": "Optional authentication token sent to the remote Trivy server. Leave empty if the server requires no token.", "security_trivy_server_note": "In client/server mode, scan containers must be able to reach the server URL. The Trivy Network setting above controls their network.", + "security_lifecycle_hooks_heading": "Lifecycle Hooks", + "security_lifecycle_enabled_label": "Enable Lifecycle Hooks", + "security_lifecycle_enabled_description": "Allow GitOps project syncs to run pre-deploy scripts before redeploying.", + "security_lifecycle_runner_image_label": "Runner Image", + "security_lifecycle_runner_image_description": "Default container image used when a GitOps sync does not override the lifecycle runner.", + "security_lifecycle_runner_image_help": "Use an image that contains the shell and tools required by your pre-deploy scripts.", + "security_lifecycle_max_timeout_label": "Max Timeout (seconds)", + "security_lifecycle_max_timeout_description": "Maximum runtime allowed for pre-deploy scripts. Set 0 to disable the cap.", + "security_lifecycle_max_timeout_help": "Per-sync timeouts above this value are clamped or rejected before deploy.", + "security_lifecycle_hooks_note": "Lifecycle hooks run repo-trusted code in a restricted container. Enable them only for repositories you control.", "security_enable_one_provider": "Enable at least one authentication provider.", "security_enable_one_provider_error": "At least one authentication provider must be enabled.", "security_form_validation_error": "Please check the form for errors.", @@ -1978,6 +1990,7 @@ "git_sync_compose_path": "Compose File Path", "git_sync_sync_files": "Sync Files", "git_sync_sync_files_description": "Copy sibling files from the compose directory into the managed project. When disabled, Arcane only syncs the selected compose file.", + "git_sync_sync_files_locked_hint": "Locked while a pre-deploy hook is configured.", "git_sync_auto_sync": "Auto Sync", "git_sync_sync_interval": "Sync Interval", "git_sync_sync_interval_description": "Minutes", @@ -2008,6 +2021,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", @@ -2034,6 +2048,32 @@ "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. Leave blank to use the environment default.", + "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.", + "git_sync_pre_deploy_managed_hint": "Managed by administrators", + "git_sync_pre_deploy_swarm_unsupported": "Pre-deploy scripts are only available for project syncs.", + "git_sync_pre_deploy_status_configured": "Configured", + "git_sync_pre_deploy_status_none": "Not configured", + "lifecycle_indicator_tooltip": "Runs a pre-deploy script: {path}", + "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 7cc1c6adbc..ca14ced166 100644 --- a/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte +++ b/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte @@ -10,31 +10,65 @@ 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 { hasPermission } from '$lib/utils/auth'; import { z } from 'zod/v4'; import { createForm, preventDefault } from '$lib/utils/settings'; import { queryKeys } from '$lib/query/query-keys'; import { m } from '$lib/paraglide/messages'; - import { ArrowRightIcon, FolderOpenIcon, InfoIcon } from '$lib/icons'; + import { ArrowRightIcon, CodeIcon, FolderOpenIcon, InfoIcon } from '$lib/icons'; import * as Alert from '$lib/components/ui/alert'; import { createQuery } from '@tanstack/svelte-query'; type GitOpsSyncFormProps = { open: boolean; syncToEdit: GitOpsSync | null; + environmentId: string; targetType?: string; onSubmit: (detail: { sync: GitOpsSyncCreateDto | GitOpsSyncUpdateDto; isEditMode: boolean }) => void; isLoading: boolean; }; - let { open = $bindable(false), syncToEdit = $bindable(), targetType, onSubmit, isLoading }: GitOpsSyncFormProps = $props(); + let { + open = $bindable(false), + syncToEdit = $bindable(), + environmentId, + targetType, + onSubmit, + isLoading + }: GitOpsSyncFormProps = $props(); type GitOpsSyncTargetType = 'project' | 'swarm_stack'; let isEditMode = $derived(!!syncToEdit); let showFileBrowser = $state(false); + let fileBrowserTarget = $state<'compose' | 'preDeployScript'>('compose'); + + // Pre-deploy lifecycle hooks run arbitrary containers (with host mounts, env, + // and network access) on every sync, so configuring them is gated behind the + // dedicated gitops:lifecycle permission rather than the broader gitops:create + // / gitops:update. Users without it see a read-only summary instead of the + // editable section and never submit its fields; the backend enforces the same + // rule as defense-in-depth. + let canManageLifecycle = $derived(hasPermission('gitops:lifecycle', environmentId)); + + // Whether the sync being edited already has a hook configured. Used to flag + // its presence to users who can't manage it and to lock the directory-sync + // toggle so they can't accidentally invalidate the hook. + let hookConfigured = $derived(!!syncToEdit?.preDeployScriptPath?.trim()); + let lockSyncDirectory = $derived(!canManageLifecycle && hookConfigured); + + const composeFileFilter = (file: FileTreeNode) => + file.type === 'file' && (file.name.endsWith('.yml') || file.name.endsWith('.yaml')); let selectedTargetType = $state('project'); const targetTypeOptions = [ @@ -56,7 +90,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; @@ -71,11 +111,14 @@ } const settingsQuery = createQuery(() => ({ - queryKey: queryKeys.settings.global(), - queryFn: () => settingsService.getSettings(), + queryKey: queryKeys.settings.byEnvironment(environmentId), + queryFn: () => settingsService.getSettingsForEnvironmentMerged(environmentId), enabled: open, - staleTime: 30_000 + staleTime: 0, + refetchOnMount: 'always' })); + const lifecycleEnabled = $derived(settingsQuery.data?.lifecycleEnabled ?? false); + const lifecycleDefaultRunnerImage = $derived(settingsQuery.data?.lifecycleDefaultRunnerImage?.trim() || 'alpine:latest'); let formData = $derived({ name: open && syncToEdit ? syncToEdit.name : '', @@ -94,7 +137,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 ?? '') : lifecycleDefaultRunnerImage, + 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)); @@ -119,6 +168,46 @@ const branches = $derived(branchesQuery.data?.branches ?? []); const loadingBranches = $derived(!!selectedRepository?.value && (branchesQuery.isPending || branchesQuery.isFetching)); + function normalizeLifecycleNetworkModeInternal(value: string | undefined | null): string { + return value?.trim() || 'none'; + } + + function existingLifecycleSnapshotInternal(sync: GitOpsSync | null) { + return { + scriptPath: sync?.preDeployScriptPath?.trim() ?? '', + runnerImage: sync?.preDeployRunnerImage?.trim() ?? '', + timeoutSec: sync?.preDeployTimeoutSec ?? 60, + networkMode: normalizeLifecycleNetworkModeInternal(sync?.preDeployNetworkMode), + env: sync?.preDeployEnv?.trim() ?? '', + extraMounts: sync?.preDeployExtraMounts?.trim() ?? '' + }; + } + + function shouldSubmitLifecycleFieldsInternal(data: z.infer): boolean { + if (!lifecycleEnabled || !canManageLifecycle || selectedTargetType !== 'project') { + return false; + } + + const scriptPath = data.preDeployScriptPath.trim(); + if (!isEditMode) { + return scriptPath !== ''; + } + + const existing = existingLifecycleSnapshotInternal(syncToEdit); + if (scriptPath === '' && existing.scriptPath === '') { + return false; + } + + return ( + scriptPath !== existing.scriptPath || + data.preDeployRunnerImage.trim() !== existing.runnerImage || + data.preDeployTimeoutSec !== existing.timeoutSec || + normalizeLifecycleNetworkModeInternal(data.preDeployNetworkMode) !== existing.networkMode || + data.preDeployEnv.trim() !== existing.env || + data.preDeployExtraMounts.trim() !== existing.extraMounts + ); + } + $effect(() => { if (open) { selectedRepository = undefined; @@ -166,6 +255,15 @@ syncInterval: data.syncInterval }; + if (shouldSubmitLifecycleFieldsInternal(data)) { + payload.preDeployScriptPath = data.preDeployScriptPath.trim(); + payload.preDeployRunnerImage = data.preDeployRunnerImage.trim(); + payload.preDeployTimeoutSec = data.preDeployTimeoutSec; + payload.preDeployNetworkMode = normalizeLifecycleNetworkModeInternal(data.preDeployNetworkMode); + payload.preDeployEnv = data.preDeployEnv.trim(); + payload.preDeployExtraMounts = data.preDeployExtraMounts.trim(); + } + onSubmit({ sync: payload, isEditMode }); } @@ -285,7 +383,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()} > @@ -298,10 +399,13 @@
- +

{m.git_sync_sync_files_description()}

+ {#if lockSyncDirectory} +

{m.git_sync_sync_files_locked_hint()}

+ {/if} {#if $inputs.syncDirectory.error}

{$inputs.syncDirectory.error}

{/if} @@ -380,6 +484,137 @@ + {#if lifecycleEnabled && canManageLifecycle && selectedTargetType === 'project'} + + + + {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} +
+ + + +
+ + +
+ + + + +
+
+
+ {:else if lifecycleEnabled && canManageLifecycle} +
+ + {m.git_sync_pre_deploy_title()} + {m.git_sync_pre_deploy_swarm_unsupported()} + +
+ {:else if lifecycleEnabled} +
+ + {m.git_sync_pre_deploy_title()} + {m.git_sync_pre_deploy_managed_hint()} + + + {#if hookConfigured} + + {m.git_sync_pre_deploy_status_configured()} + {:else} + {m.git_sync_pre_deploy_status_none()} + {/if} + +
+ {/if} + @@ -412,11 +647,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/lifecycle-indicator/index.ts b/frontend/src/lib/components/lifecycle-indicator/index.ts new file mode 100644 index 0000000000..51285e795c --- /dev/null +++ b/frontend/src/lib/components/lifecycle-indicator/index.ts @@ -0,0 +1 @@ +export { default as LifecycleIndicator } from './lifecycle-indicator.svelte'; diff --git a/frontend/src/lib/components/lifecycle-indicator/lifecycle-indicator.svelte b/frontend/src/lib/components/lifecycle-indicator/lifecycle-indicator.svelte new file mode 100644 index 0000000000..84bd2407db --- /dev/null +++ b/frontend/src/lib/components/lifecycle-indicator/lifecycle-indicator.svelte @@ -0,0 +1,28 @@ + + +{#if trimmedPath} + + + + + + + {tooltipText} + +{/if} diff --git a/frontend/src/lib/components/settings/lifecycle-security-settings.svelte b/frontend/src/lib/components/settings/lifecycle-security-settings.svelte new file mode 100644 index 0000000000..7e241d0c1f --- /dev/null +++ b/frontend/src/lib/components/settings/lifecycle-security-settings.svelte @@ -0,0 +1,80 @@ + + + + +
+ +

{m.security_lifecycle_hooks_heading()}

+
+
+
+ + + + + +
+ +
+ +
+ +
+ +
+ + + + {m.security_lifecycle_hooks_note()} + + +
+
+
diff --git a/frontend/src/lib/query/query-keys.ts b/frontend/src/lib/query/query-keys.ts index 0872ec87a1..23b75d9ed4 100644 --- a/frontend/src/lib/query/query-keys.ts +++ b/frontend/src/lib/query/query-keys.ts @@ -128,7 +128,8 @@ export const queryKeys = { gitOpsSyncs: { all: ['gitops-syncs'] as const, list: (environmentId: string, options: SearchPaginationSortRequest) => - ['gitops-syncs', environmentId, stableSerialize(options)] as const + ['gitops-syncs', environmentId, stableSerialize(options)] as const, + detail: (environmentId: string, syncId: string) => ['gitops-syncs', environmentId, syncId] as const }, volumes: { table: (environmentId: string, options: SearchPaginationSortRequest) => diff --git a/frontend/src/lib/types/automation.ts b/frontend/src/lib/types/automation.ts index 9bca44cbb7..8a72b61c7c 100644 --- a/frontend/src/lib/types/automation.ts +++ b/frontend/src/lib/types/automation.ts @@ -128,6 +128,12 @@ export interface GitOpsSyncCreateDto { maxSyncFiles?: number; maxSyncTotalSize?: number; maxSyncBinarySize?: number; + preDeployScriptPath?: string; + preDeployRunnerImage?: string; + preDeployEnv?: string; + preDeployExtraMounts?: string; + preDeployTimeoutSec?: number; + preDeployNetworkMode?: string; } export interface GitOpsSyncUpdateDto { @@ -143,6 +149,12 @@ export interface GitOpsSyncUpdateDto { maxSyncFiles?: number; maxSyncTotalSize?: number; maxSyncBinarySize?: number; + preDeployScriptPath?: string; + preDeployRunnerImage?: string; + preDeployEnv?: string; + preDeployExtraMounts?: string; + preDeployTimeoutSec?: number; + preDeployNetworkMode?: string; } export interface GitOpsSync { @@ -167,6 +179,15 @@ export interface GitOpsSync { lastSyncStatus?: string; lastSyncError?: string; lastSyncCommit?: string; + preDeployScriptPath?: string; + preDeployRunnerImage?: string; + preDeployEnv?: string; + preDeployExtraMounts?: string; + preDeployTimeoutSec: number; + preDeployNetworkMode: string; + preDeployLastRunAt?: string; + preDeployLastRunStatus?: string; + preDeployLastRunOutput?: string; createdAt: string; updatedAt: string; } diff --git a/frontend/src/lib/types/settings.ts b/frontend/src/lib/types/settings.ts index 31c53e3eed..fa781b1448 100644 --- a/frontend/src/lib/types/settings.ts +++ b/frontend/src/lib/types/settings.ts @@ -78,6 +78,9 @@ export type Settings = { trivyServerUrl: string; trivyServerToken: string; trivyIgnoreUnfixed: boolean; + lifecycleEnabled: boolean; + lifecycleDefaultRunnerImage: string; + lifecycleMaxTimeoutSec: number; oidcEnabled: boolean; oidcClientId: string; oidcClientSecret?: string; diff --git a/frontend/src/routes/(app)/environments/[id]/+page.svelte b/frontend/src/routes/(app)/environments/[id]/+page.svelte index cf0b83290a..f375f4ad88 100644 --- a/frontend/src/routes/(app)/environments/[id]/+page.svelte +++ b/frontend/src/routes/(app)/environments/[id]/+page.svelte @@ -28,6 +28,7 @@ import JobsTab from './components/JobsTab.svelte'; import { environmentFormSchema, type EnvironmentFormValues } from './components/environment-form-schema'; import TrivySecuritySettings from '$lib/components/settings/trivy-security-settings.svelte'; + import LifecycleSecuritySettings from '$lib/components/settings/lifecycle-security-settings.svelte'; import { ArrowLeftIcon, AlertIcon, @@ -52,6 +53,7 @@ let currentEnvironment = $derived(environmentStore.selected); let activeTab = $state('general'); + let activeSecurityTab = $state('trivy'); let isRefreshing = $state(false); let isTestingConnection = $state(false); @@ -173,6 +175,18 @@ }); const tabValues = $derived(new Set(tabItems.map((tab) => tab.value))); + const securityTabItems = $derived.by((): TabItem[] => [ + { + value: 'trivy', + label: m.security_trivy_tab(), + icon: SecurityIcon + }, + { + value: 'lifecycle', + label: m.security_lifecycle_tab(), + icon: GitBranchIcon + } + ]); $effect(() => { if (!tabValues.has(activeTab)) { @@ -246,6 +260,9 @@ trivyServerUrl: settings?.trivyServerUrl || '', trivyServerToken: settings?.trivyServerToken || '', trivyIgnoreUnfixed: settings?.trivyIgnoreUnfixed ?? true, + lifecycleEnabled: settings?.lifecycleEnabled ?? false, + lifecycleDefaultRunnerImage: settings?.lifecycleDefaultRunnerImage || 'alpine:latest', + lifecycleMaxTimeoutSec: settings?.lifecycleMaxTimeoutSec ?? 300, autoUpdateExcludedContainers: settings?.autoUpdateExcludedContainers || '', autoHealEnabled: settings?.autoHealEnabled ?? false, autoHealExcludedContainers: settings?.autoHealExcludedContainers || '', @@ -304,6 +321,9 @@ trivyServerUrl: formData.trivyServerUrl, trivyServerToken: formData.trivyServerToken, trivyIgnoreUnfixed: formData.trivyIgnoreUnfixed, + lifecycleEnabled: formData.lifecycleEnabled, + lifecycleDefaultRunnerImage: formData.lifecycleDefaultRunnerImage, + lifecycleMaxTimeoutSec: formData.lifecycleMaxTimeoutSec, autoUpdateExcludedContainers: formData.autoUpdateExcludedContainers, autoHealEnabled: formData.autoHealEnabled, autoHealExcludedContainers: formData.autoHealExcludedContainers, @@ -652,7 +672,17 @@ - + + (activeSecurityTab = value)} /> + + + + + + + + + diff --git a/frontend/src/routes/(app)/environments/[id]/components/environment-form-schema.ts b/frontend/src/routes/(app)/environments/[id]/components/environment-form-schema.ts index bf701bce1f..f78e4d192f 100644 --- a/frontend/src/routes/(app)/environments/[id]/components/environment-form-schema.ts +++ b/frontend/src/routes/(app)/environments/[id]/components/environment-form-schema.ts @@ -45,6 +45,9 @@ export const environmentFormSchema = z trivyServerUrl: z.string(), trivyServerToken: z.string(), trivyIgnoreUnfixed: z.boolean(), + lifecycleEnabled: z.boolean(), + lifecycleDefaultRunnerImage: z.string(), + lifecycleMaxTimeoutSec: z.coerce.number().int().nonnegative(), autoUpdateExcludedContainers: z.string().optional(), autoHealEnabled: z.boolean(), autoHealExcludedContainers: z.string(), diff --git a/frontend/src/routes/(app)/environments/[id]/gitops/+page.svelte b/frontend/src/routes/(app)/environments/[id]/gitops/+page.svelte index b5646564b9..0295d020a7 100644 --- a/frontend/src/routes/(app)/environments/[id]/gitops/+page.svelte +++ b/frontend/src/routes/(app)/environments/[id]/gitops/+page.svelte @@ -9,8 +9,7 @@ } from '$lib/types/automation'; import GitOpsSyncFormSheet from '$lib/components/dialogs/gitops-sync-dialog.svelte'; import GitOpsImportDialog from '$lib/components/dialogs/gitops-import-dialog.svelte'; - import { handleApiResultWithCallbacks } from '$lib/utils/api'; - import { tryCatch } from '$lib/utils/api'; + import { extractApiErrorMessage, handleApiResultWithCallbacks, tryCatch } from '$lib/utils/api'; import { m } from '$lib/paraglide/messages'; import { gitOpsSyncService } from '$lib/services/gitops-sync-service'; import { untrack } from 'svelte'; @@ -108,7 +107,10 @@ isSyncDialogOpen = false; } catch (error) { console.error('Error saving sync:', error); - toast.error(error instanceof Error ? error.message : m.common_save_failed()); + const title = isEditMode + ? m.common_update_failed({ resource: m.resource_sync() }) + : m.common_create_failed({ resource: m.resource_sync() }); + toast.error(title, { description: extractApiErrorMessage(error) }); } finally { isLoading[loadingKey] = false; } @@ -219,6 +221,7 @@ import ArcaneTable from '$lib/components/arcane-table/arcane-table.svelte'; import StatusBadge from '$lib/components/badges/status-badge.svelte'; + import { LifecycleIndicator } from '$lib/components/lifecycle-indicator'; import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import { toast } from 'svelte-sonner'; @@ -190,13 +191,16 @@ {#snippet NameCell({ item, value }: { item: GitOpsSync; value: any; row: ArcaneRow })} - {#if item.projectId} - - {value} - - {:else} - {value} - {/if} + + {#if item.projectId} + + {value} + + {:else} + {value} + {/if} + + {/snippet} {#snippet BranchCell({ value }: { value: any; item: GitOpsSync; row: ArcaneRow })} diff --git a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte index fef950fcb0..2fe21f7d21 100644 --- a/frontend/src/routes/(app)/projects/[projectId]/+page.svelte +++ b/frontend/src/routes/(app)/projects/[projectId]/+page.svelte @@ -14,7 +14,8 @@ SettingsIcon, FileTextIcon, AlertIcon, - GlobeIcon + GlobeIcon, + CodeIcon } from '$lib/icons'; import { type TabItem } from '$lib/components/tab-bar/index.js'; import TabbedPageLayout from '$lib/layouts/tabbed-page-layout.svelte'; @@ -112,6 +113,21 @@ refetchOnMount: false })); + const lifecycleSyncQuery = createQuery(() => { + const syncId = data.project?.gitOpsManagedBy; + return { + queryKey: queryKeys.gitOpsSyncs.detail(envId, syncId ?? 'none'), + queryFn: () => gitOpsSyncService.getSync(envId, syncId!), + enabled: !!syncId, + staleTime: 30_000 + }; + }); + + const lifecycleSync = $derived(lifecycleSyncQuery.data); + const hasLifecycleHook = $derived( + !!(lifecycleSync?.preDeployScriptPath && lifecycleSync.preDeployScriptPath.trim().length > 0) + ); + const formSchema = z .object({ name: z.string().min(1, m.compose_project_name_required()), @@ -287,9 +303,15 @@ pending: treeTabPending(key) })) ); + // Validity check covers both include files (editable) and directory files + // (read-only — currently used to surface the pre-deploy script alongside + // includes in classic layout). A path that no longer exists in either set + // is forgotten so a removed file doesn't leave a dangling tab selection. const selectedIncludeTab = $derived.by(() => { if (!selectedIncludeTabPreference) return null; - return includeFilePaths.has(selectedIncludeTabPreference) ? selectedIncludeTabPreference : null; + if (includeFilePaths.has(selectedIncludeTabPreference)) return selectedIncludeTabPreference; + if (directoryFilePaths.has(selectedIncludeTabPreference)) return selectedIncludeTabPreference; + return null; }); let composeHasChanges = $derived($inputs.composeContent.value !== serverComposeContent); let envHasChanges = $derived($inputs.envContent.value !== serverEnvContent); @@ -1288,6 +1310,18 @@ {/if}
{/if} + {#if hasLifecycleHook && lifecycleSync?.preDeployScriptPath} +
+ {m.git_sync_pre_deploy_title()}: + {lifecycleSync.preDeployScriptPath} + + {m.lifecycle_inline_runner_summary({ + image: lifecycleSync.preDeployRunnerImage || 'alpine:latest', + network: lifecycleSync.preDeployNetworkMode || 'none' + })} + +
+ {/if} {m.git_managed_env_note()} @@ -1483,10 +1517,10 @@
{:else}
- {#if project?.includeFiles && project.includeFiles.length > 0} + {#if (project?.includeFiles && project.includeFiles.length > 0) || (hasLifecycleHook && lifecycleSync?.preDeployScriptPath && directoryFilePaths.has(lifecycleSync.preDeployScriptPath))}
- {#each project.includeFiles as includeFile (includeFile.relativePath)} + {#each project?.includeFiles ?? [] as includeFile (includeFile.relativePath)} {/each} + {#if hasLifecycleHook && lifecycleSync?.preDeployScriptPath && directoryFilePaths.has(lifecycleSync.preDeployScriptPath)} + {@const scriptPath = lifecycleSync.preDeployScriptPath} + toggleIncludeFileTab(scriptPath)} + icon={CodeIcon} + customLabel={scriptPath} + /> + {/if}
{/if} {#if selectedIncludeTab} {@const includeFile = project?.includeFiles?.find((f) => f.relativePath === selectedIncludeTab)} - {#if includeFile} - {#await getProjectFileResource('include', includeFile.relativePath)} -
- {m.common_loading()} -
- {:then} + {@const dirFile = !includeFile + ? project?.directoryFiles?.find((f) => f.relativePath === selectedIncludeTab) + : undefined} + {@const fileKind = includeFile ? 'include' : 'directory'} + {#await getProjectFileResource(fileKind, selectedIncludeTab)} +
+ {m.common_loading()} +
+ {:then loaded} + {#if includeFile} - {:catch error} -
- {error instanceof Error ? error.message : String(error)} -
- {/await} - {/if} + {:else if dirFile} + + {/if} + {:catch error} +
+ {error instanceof Error ? error.message : String(error)} +
+ {/await} {:else}