From 8b9b9e80937524924826e8a8fb16fa8bf185b1e0 Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Mon, 1 Jun 2026 11:37:08 +1000 Subject: [PATCH 01/12] chore: enforce LF for husky hooks, Justfile, and shell scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These files break under CRLF on Linux: husky hooks fail with "command not found" because bash chokes on \r in shebangs, just fails to parse Justfile, and shell scripts copied into runner containers exec strangely. The .gitattributes here is intentionally narrow — only the tools that actually break, not a repo-wide normalization that would re-touch every text file on contributors' machines with autocrlf configured differently. --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..5bcb23fcd6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Files that must be LF on disk regardless of the checkout platform, because +# they're consumed by tooling that chokes on CRLF (bash shebangs, just, +# shell scripts copied verbatim into Linux runner containers). +.husky/* text eol=lf +.husky/_/* text eol=lf +Justfile text eol=lf +*.sh text eol=lf From 53fba62a26cde531532679a58281f6db98a0d08c Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Mon, 1 Jun 2026 11:37:56 +1000 Subject: [PATCH 02/12] feat(lifecycle): pre-deploy hook backend for gitops syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pre-deploy lifecycle hook capability to GitOps syncs: each sync can configure a script (committed in the repo) that runs in a throwaway container immediately before the linked project deploys. The canonical use case is decrypting/generating files compose then consumes via env_file: or .env — e.g. `sops -d secrets.enc.env > .env.runtime` — without any Arcane-specific machinery in the loop. Schema - Migration 061 adds pre_deploy_{script_path, runner_image, env, extra_mounts, timeout_sec, network_mode, last_run_at, last_run_status, last_run_output} columns to gitops_syncs. - Postgres ↔ SQLite parity; defaults preserve existing rows. Service - LifecycleService.RunPreDeploy is invoked from DeployProject before the compose project is loaded, so the hook can produce files the compose later parses. A non-zero exit or timeout aborts the deploy. - Runner container hardening: - SecurityOpt: no-new-privileges:true - CapDrop: ALL (drops the default capability set Docker grants root inside the container) - NetworkMode defaults to "none" so the script has no outbound access unless explicitly opted in via the dialog - Workspace mount is RW so artifacts persist into the project - MountForSubpath helper picks TypeBind for host-bind /app/data and TypeVolume + VolumeOptions.Subpath for named-volume backed setups — fixes both Docker-Desktop-on-WSL2 dev and named-volume production deploys - Script + extra-mount paths validated against POSIX semantics (path.IsAbs over filepath.ToSlash) so the validator behaves the same on Linux servers and Windows-based contributor machines. GitOps sync integration - Validator returns *models.ValidationError with a Field name so the API surfaces 400 + structured details for field-attribution. - redeployAfterSyncFailedError surfaces auto-sync redeploy failures on the sync row's LastSyncError without losing the synced-file list. - GitOps sync error wrappers now surface the inner error via "%v" (matched to RegistryCreationError pattern) so dialog toasts show the actual reason. Files preservation - WriteSyncedDirectory honors SyncFile.Executable (sourced from git's 100755 bit via the walker) and re-chmods on update so a hook script committed as executable arrives in the workspace runnable. Settings - LifecycleEnabled (kill switch, default off) and LifecycleMaxTimeoutSec (cap, default 300s) added to the AppSettings struct and the Settings Update DTO with envOverride so admins can disable hooks via env without DB access. --- backend/internal/common/errors.go | 4 +- backend/internal/configschema/schema_test.go | 2 + backend/internal/di/providers.go | 5 +- backend/internal/di/wire.go | 1 + backend/internal/di/wire_gen.go | 6 +- backend/internal/models/event.go | 5 + backend/internal/models/gitops_sync.go | 18 + backend/internal/models/settings.go | 2 + .../services/dashboard_service_test.go | 2 +- .../internal/services/gitops_sync_service.go | 285 ++++++- .../services/gitops_sync_service_test.go | 252 +++++- .../internal/services/lifecycle_service.go | 727 ++++++++++++++++++ .../services/lifecycle_service_test.go | 418 ++++++++++ backend/internal/services/project_service.go | 40 +- .../internal/services/project_service_test.go | 154 ++-- backend/internal/services/settings_service.go | 2 + .../internal/services/updater_service_test.go | 2 +- backend/pkg/dockerutil/mount_utils.go | 107 +++ backend/pkg/dockerutil/mount_utils_test.go | 82 ++ backend/pkg/gitutil/git.go | 10 + backend/pkg/gitutil/git_test.go | 32 + backend/pkg/projects/fs_writer.go | 17 +- backend/pkg/projects/fs_writer_test.go | 53 ++ backend/pkg/projects/load.go | 1 + .../061_add_gitops_sync_pre_deploy_hook.sql | 31 + .../061_add_gitops_sync_pre_deploy_hook.sql | 31 + types/gitops/gitops.go | 139 ++++ types/settings/settings.go | 13 + 28 files changed, 2333 insertions(+), 108 deletions(-) create mode 100644 backend/internal/services/lifecycle_service.go create mode 100644 backend/internal/services/lifecycle_service_test.go create mode 100644 backend/resources/migrations/postgres/061_add_gitops_sync_pre_deploy_hook.sql create mode 100644 backend/resources/migrations/sqlite/061_add_gitops_sync_pre_deploy_hook.sql diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 146aef6d99..9a134d0021 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -1553,7 +1553,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 { @@ -1569,7 +1569,7 @@ type GitOpsSyncUpdateError struct { } func (e *GitOpsSyncUpdateError) Error() string { - return "Failed to update GitOps sync" + return fmt.Sprintf("Failed to update GitOps sync: %v", e.Err) } type GitOpsSyncDeletionError struct { diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index 7d6dd73f5b..ddbf4a61a5 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -304,6 +304,8 @@ var expectedSettingOverrideKeys = []string{ "httpClientTimeout", "iconCatalog", "keyboardShortcutsEnabled", + "lifecycleEnabled", + "lifecycleMaxTimeoutSec", "maxImageUploadSize", "mobileNavigationMode", "mobileNavigationShowLabels", diff --git a/backend/internal/di/providers.go b/backend/internal/di/providers.go index df932e557b..7047e0e920 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, 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, environment *services.EnvironmentService, cfg *config.Config) *services.ProjectService { + return services.NewProjectService(db, settings, event, image, docker, build, lifecycle, cfg). 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 3cff01377a..0b7804e411 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, environmentService, cfg) + lifecycleService := services.NewLifecycleService(db, settingsService, eventService, dockerClientService) + projectService := provideProjectServiceInternal(db, settingsService, eventService, imageService, dockerClientService, buildService, lifecycleService, 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 9d15cc7a32..335b840477 100644 --- a/backend/internal/models/event.go +++ b/backend/internal/models/event.go @@ -89,6 +89,11 @@ const ( EventTypeWebhookDelete EventType = "webhook.delete" EventTypeWebhookTrigger EventType = "webhook.trigger" + // EventTypeLifecycleExecute is emitted by LifecycleService each time a + // pre-deploy script runs. Severity is success on a clean exit and warning + // on non-zero exit or timeout. + EventTypeLifecycleExecute EventType = "lifecycle.execute" + // EventSeverityInfo and the constants below enumerate event severities. EventSeverityInfo EventSeverity = "info" EventSeverityWarning EventSeverity = "warning" diff --git a/backend/internal/models/gitops_sync.go b/backend/internal/models/gitops_sync.go index b83bffbb35..cb9dad3fa1 100644 --- a/backend/internal/models/gitops_sync.go +++ b/backend/internal/models/gitops_sync.go @@ -29,6 +29,24 @@ type GitOpsSync struct { LastSyncStatus *string `json:"lastSyncStatus,omitempty" search:"status,success,failed,pending,error"` LastSyncError *string `json:"lastSyncError,omitempty"` LastSyncCommit *string `json:"lastSyncCommit,omitempty" search:"commit,hash,sha,revision"` + + // Pre-deploy lifecycle hook (configuration) + // When PreDeployScriptPath is set, the named script is executed in a + // throwaway container before each deploy of the linked project. The script, + // runner image, and execution context together act as repo-trusted code — + // any push to the repo that changes the script will run unreviewed on the + // next deploy. See docs for details. + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty" gorm:"column:pre_deploy_script_path" search:"lifecycle,hook,pre-deploy,script,path"` + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty" gorm:"column:pre_deploy_runner_image"` + PreDeployEnv *string `json:"preDeployEnv,omitempty" gorm:"column:pre_deploy_env"` // KEY=VALUE lines, one per line; same format as .env files + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty" gorm:"column:pre_deploy_extra_mounts"` // docker -v style "src:tgt[:ro|:rw]" entries, one per line + PreDeployTimeoutSec int `json:"preDeployTimeoutSec" gorm:"column:pre_deploy_timeout_sec;default:60"` + PreDeployNetworkMode string `json:"preDeployNetworkMode" gorm:"column:pre_deploy_network_mode;default:'none'"` // Docker network mode passed to the runner container. Default "none" denies network access; set to "bridge", "host", or a named network when the script needs it. + + // Pre-deploy lifecycle hook (last-run state) + PreDeployLastRunAt *time.Time `json:"preDeployLastRunAt,omitempty" gorm:"column:pre_deploy_last_run_at" sortable:"true"` + PreDeployLastRunStatus *string `json:"preDeployLastRunStatus,omitempty" gorm:"column:pre_deploy_last_run_status" sortable:"true"` // "success" | "failed" | "timeout" + PreDeployLastRunOutput *string `json:"preDeployLastRunOutput,omitempty" gorm:"column:pre_deploy_last_run_output"` // truncated stdout+stderr } func (GitOpsSync) TableName() string { diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 656d95f797..8a2e2a921f 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -143,6 +143,8 @@ 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"` + 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 e1edad2e1f..23808009e9 100644 --- a/backend/internal/services/dashboard_service_test.go +++ b/backend/internal/services/dashboard_service_test.go @@ -155,7 +155,7 @@ func TestDashboardService_GetSnapshot_ReturnsDashboardSnapshot(t *testing.T) { Path: projectPath, Status: models.ProjectStatusStopped, }).Error) - projectSvc := NewProjectService(db, settingsSvc, nil, &ImageService{db: db}, nil, nil, config.Load()) + projectSvc := NewProjectService(db, settingsSvc, nil, &ImageService{db: db}, nil, nil, nil, config.Load()) svc := NewDashboardService(db, dockerSvc, nil, projectSvc, nil, settingsSvc, nil, nil, nil) snapshot, err := svc.GetSnapshot(context.Background(), DashboardActionItemsOptions{}) diff --git a/backend/internal/services/gitops_sync_service.go b/backend/internal/services/gitops_sync_service.go index 1c4c72696c..6ea1d9317b 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" @@ -67,6 +68,23 @@ const ( defaultMaxSyncBinarySize = defaultMaxSyncBinarySizeMB * 1024 * 1024 ) +// redeployAfterSyncFailedError is returned by the internal sync flow when +// the file sync succeeded but the auto-redeploy that follows it failed +// (e.g. a pre-deploy lifecycle hook returned non-zero). Callers surface +// this on the sync row's LastSyncError so the failure is visible from the +// gitops UI rather than only in the logs. +type redeployAfterSyncFailedError struct { + cause error +} + +func (e redeployAfterSyncFailedError) Error() string { + return "redeploy failed: " + e.cause.Error() +} + +func (e redeployAfterSyncFailedError) Unwrap() error { + return e.cause +} + // preparedSyncSource captures the repository data needed by the sync execution // paths after the source repository has been cloned and validated. type preparedSyncSource struct { @@ -105,6 +123,196 @@ func validateSyncLimits(maxFiles *int, maxTotalSize, maxBinarySize *int64) error return nil } +// lifecycleConfigInputInternal is the slice of CreateSyncRequest / +// UpdateSyncRequest fields relevant to the pre-deploy lifecycle hook. Both +// request types collapse into the same shape so a single validator handles +// both flows. +type lifecycleConfigInputInternal struct { + scriptPath *string + runnerImage *string + env *string + extraMounts *string + timeoutSec *int + networkMode *string + syncDirectory *bool +} + +func (s *GitOpsSyncService) validateLifecycleConfigInternal(ctx context.Context, current *models.GitOpsSync, in lifecycleConfigInputInternal) error { + lifecycleFieldSet := in.scriptPath != nil || in.runnerImage != nil || in.env != nil || in.extraMounts != nil || in.timeoutSec != nil || in.networkMode != nil + syncDirectoryChanging := in.syncDirectory != nil + effectiveScriptPath := resolveLifecycleEffectiveStringInternal(currentString(current, func(c *models.GitOpsSync) *string { return c.PreDeployScriptPath }), in.scriptPath) + + // If nothing lifecycle-related is being touched and the resulting state + // has no script configured, there's nothing to validate. + if !lifecycleFieldSet && (!syncDirectoryChanging || effectiveScriptPath == "") { + return nil + } + + // Kill switch only blocks updates that actively touch lifecycle fields; + // toggling unrelated fields on an existing config shouldn't be rejected + // just because the global setting was turned off afterwards. + if lifecycleFieldSet && !s.settingsService.GetBoolSetting(ctx, "lifecycleEnabled", false) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy lifecycle hooks are disabled. An admin must enable lifecycleEnabled in settings before they can be configured."} + } + + if effectiveScriptPath != "" { + if len(effectiveScriptPath) > 256 { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be 256 characters or fewer."} + } + // scriptPath is a POSIX repo path, not a host path; use path.IsAbs so the + // check behaves the same on Windows-based contributor machines. + if path.IsAbs(filepath.ToSlash(effectiveScriptPath)) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be relative to the project directory."} + } + cleaned := filepath.ToSlash(filepath.Clean(effectiveScriptPath)) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must not escape the project directory."} + } + + effectiveRunnerImage := resolveLifecycleEffectiveStringInternal(currentString(current, func(c *models.GitOpsSync) *string { return c.PreDeployRunnerImage }), in.runnerImage) + if effectiveRunnerImage == "" { + return &models.ValidationError{Field: "preDeployRunnerImage", Message: "Runner image is required when a script path is set."} + } + + if !resolveEffectiveSyncDirectoryInternal(current, in.syncDirectory) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy script requires \"Sync entire directory\" so the script is included in the synced files."} + } + } + + if in.timeoutSec != nil { + if *in.timeoutSec < 1 { + return &models.ValidationError{Field: "preDeployTimeoutSec", Message: "Timeout must be at least 1 second."} + } + maxTimeoutSec := s.settingsService.GetIntSetting(ctx, "lifecycleMaxTimeoutSec", lifecycleDefaultMaxTimeoutSec) + if maxTimeoutSec > 0 && *in.timeoutSec > maxTimeoutSec { + return &models.ValidationError{Field: "preDeployTimeoutSec", Message: fmt.Sprintf("Timeout %ds exceeds the lifecycleMaxTimeoutSec setting (%ds).", *in.timeoutSec, maxTimeoutSec)} + } + } + + if _, err := parseLifecycleEnvTextInternal(in.env); err != nil { + return &models.ValidationError{Field: "preDeployEnv", Message: err.Error()} + } + if _, err := parseLifecycleExtraMountsTextInternal(in.extraMounts); err != nil { + return &models.ValidationError{Field: "preDeployExtraMounts", Message: err.Error()} + } + + return nil +} + +// resolveLifecycleEffectiveStringInternal computes the value a string field +// will take after an update: a nil update leaves the existing value, a +// non-nil update (including empty string, which clears) overrides it. Used +// to validate the post-update state of a sync record. +func resolveLifecycleEffectiveStringInternal(existing string, update *string) string { + if update != nil { + return strings.TrimSpace(*update) + } + return strings.TrimSpace(existing) +} + +func currentString(sync *models.GitOpsSync, accessor func(*models.GitOpsSync) *string) string { + if sync == nil { + return "" + } + if p := accessor(sync); p != nil { + return *p + } + return "" +} + +// resolveEffectiveSyncDirectoryInternal mirrors the string resolver but for +// the SyncDirectory bool: a nil update keeps the existing value, a non-nil +// update overrides it. On create (current=nil) and no update, defaults to +// false to match the model's create-time default. +func resolveEffectiveSyncDirectoryInternal(current *models.GitOpsSync, update *bool) bool { + if update != nil { + return *update + } + if current != nil { + return current.SyncDirectory + } + return false +} + +// applyLifecycleFieldsToSyncInternal copies lifecycle config from a Create +// request into a new GitOpsSync row before insert. Strings are trimmed; +// empty strings remain unset (nil pointer) except for fields that have a +// non-null default at the DB level. +func applyLifecycleFieldsToSyncInternal(sync *models.GitOpsSync, in lifecycleConfigInputInternal) { + sync.PreDeployScriptPath = nullableTrimmedStringInternal(in.scriptPath) + sync.PreDeployRunnerImage = nullableTrimmedStringInternal(in.runnerImage) + sync.PreDeployEnv = nullableTrimmedStringInternal(in.env) + sync.PreDeployExtraMounts = nullableTrimmedStringInternal(in.extraMounts) + if in.timeoutSec != nil { + sync.PreDeployTimeoutSec = *in.timeoutSec + } + if mode := normalizeLifecycleNetworkModeInternal(in.networkMode); mode != "" { + sync.PreDeployNetworkMode = mode + } +} + +// addLifecycleUpdatesInternal appends lifecycle field updates to the GORM +// updates map. nil pointers leave the field unchanged; non-nil empty strings +// clear nullable fields to NULL and reset fixed-default fields to their +// default value. +func addLifecycleUpdatesInternal(updates map[string]any, in lifecycleConfigInputInternal) { + if in.scriptPath != nil { + updates["pre_deploy_script_path"] = nullableUpdateStringValueInternal(in.scriptPath) + } + if in.runnerImage != nil { + updates["pre_deploy_runner_image"] = nullableUpdateStringValueInternal(in.runnerImage) + } + if in.env != nil { + updates["pre_deploy_env"] = nullableUpdateStringValueInternal(in.env) + } + if in.extraMounts != nil { + updates["pre_deploy_extra_mounts"] = nullableUpdateStringValueInternal(in.extraMounts) + } + if in.timeoutSec != nil { + updates["pre_deploy_timeout_sec"] = *in.timeoutSec + } + if in.networkMode != nil { + updates["pre_deploy_network_mode"] = normalizeLifecycleNetworkModeInternal(in.networkMode) + } +} + +// normalizeLifecycleNetworkModeInternal returns the canonical network mode +// string for a user-supplied value. Empty / whitespace / unset all map to +// "none" so the secure-by-default behaviour is restored when the user clears +// the field. +func normalizeLifecycleNetworkModeInternal(p *string) string { + if p == nil { + return "none" + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return "none" + } + return trimmed +} + +func nullableTrimmedStringInternal(p *string) *string { + if p == nil { + return nil + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return nil + } + return &trimmed +} + +func nullableUpdateStringValueInternal(p *string) any { + if p == nil { + return nil + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return nil + } + return trimmed +} + func normalizeSyncLimitSetting(value, defaultValue int) int { if value < 0 { return defaultValue @@ -426,7 +634,23 @@ func (s *GitOpsSyncService) CreateSync(ctx context.Context, environmentID string sync.MaxSyncBinarySize = *req.MaxSyncBinarySize } - if err := s.db.WithContext(ctx).Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { + lifecycleCfg := lifecycleConfigInputInternal{ + scriptPath: req.PreDeployScriptPath, + runnerImage: req.PreDeployRunnerImage, + env: req.PreDeployEnv, + extraMounts: req.PreDeployExtraMounts, + timeoutSec: req.PreDeployTimeoutSec, + networkMode: req.PreDeployNetworkMode, + syncDirectory: req.SyncDirectory, + } + if err := s.validateLifecycleConfigInternal(ctx, nil, lifecycleCfg); err != nil { + return nil, err + } + applyLifecycleFieldsToSyncInternal(&sync, lifecycleCfg) + + // Select("*") forces explicit zero values (e.g. "0 = unlimited" sync limits and + // unset pre-deploy fields) to persist instead of GORM substituting column defaults. + if err := s.db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { //nolint:unqueryvet // intentional Select("*"); see comment above slog.ErrorContext(ctx, "Failed to create GitOps sync in database", "name", req.Name, "repositoryID", req.RepositoryID, "environmentID", environmentID, "error", err) return nil, fmt.Errorf("failed to create sync: %w", err) } @@ -524,6 +748,20 @@ func (s *GitOpsSyncService) UpdateSync(ctx context.Context, environmentID, id st updates["max_sync_binary_size"] = *req.MaxSyncBinarySize } + lifecycleCfg := lifecycleConfigInputInternal{ + scriptPath: req.PreDeployScriptPath, + runnerImage: req.PreDeployRunnerImage, + env: req.PreDeployEnv, + extraMounts: req.PreDeployExtraMounts, + timeoutSec: req.PreDeployTimeoutSec, + networkMode: req.PreDeployNetworkMode, + syncDirectory: req.SyncDirectory, + } + if err := s.validateLifecycleConfigInternal(ctx, sync, lifecycleCfg); err != nil { + return nil, err + } + addLifecycleUpdatesInternal(updates, lifecycleCfg) + if len(updates) > 0 { if err := s.db.WithContext(ctx).Model(sync).Updates(updates).Error; err != nil { return nil, fmt.Errorf("failed to update sync: %w", err) @@ -723,7 +961,13 @@ func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *mode } if contentsChanged { - s.redeployIfRunningAfterSync(ctx, project, actor, "directory") + if redeployErr := s.redeployIfRunningAfterSync(ctx, project, actor, "directory"); redeployErr != nil { + if typed, ok := errors.AsType[redeployAfterSyncFailedError](redeployErr); ok { + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, typed, actor, result) + return result, redeployErr + } + return result, redeployErr + } } s.updateSyncStatusWithFiles(ctx, id, "success", "", source.commitHash, syncedFiles) @@ -741,6 +985,11 @@ func (s *GitOpsSyncService) performSingleFileSyncInternal(ctx context.Context, s project, err := s.getOrCreateProjectInternal(ctx, sync, id, source.composeContent, source.envContent, result, actor) if err != nil { + if redeployErr, ok := errors.AsType[redeployAfterSyncFailedError](err); ok { + syncedFiles := []string{filepath.Base(sync.ComposePath)} + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, redeployErr, actor, result) + return result, err + } return result, err } @@ -825,20 +1074,24 @@ func (s *GitOpsSyncService) performSwarmStackSyncInternal(ctx context.Context, s } // redeployIfRunningAfterSync redeploys a project only when it is already -// running and the latest sync actually changed managed content. -func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) { +// running and the latest sync actually changed managed content. Returns a +// redeployAfterSyncFailedError when the redeploy itself fails; callers +// surface that on the sync row's LastSyncError. +func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) error { details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails()) if err != nil { - return + return nil //nolint:nilerr // best-effort: skip post-sync redeploy when project state can't be determined } if details.Status != string(models.ProjectStatusRunning) && details.Status != string(models.ProjectStatusPartiallyRunning) { - return + return nil } slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "syncMode", syncMode, "projectName", project.Name, "projectId", project.ID) if err := s.projectService.RedeployProject(ctx, project.ID, actor, nil); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "syncMode", syncMode, "error", err, "projectId", project.ID) + return redeployAfterSyncFailedError{cause: err} } + return nil } // logSyncSuccess records the Git sync completion event once the filesystem and @@ -1041,6 +1294,20 @@ func (s *GitOpsSyncService) failSync(ctx context.Context, id string, result *git return fmt.Errorf("%s", errMsg) } +// markSyncRedeployFailedInternal records a sync where the file sync wrote +// cleanly but the auto-redeploy that follows failed (typically a pre-deploy +// lifecycle hook returning non-zero). The synced-files list and commit hash +// are preserved on the row so operators can see what reached disk; the +// error message surfaces the redeploy failure on LastSyncError. +func (s *GitOpsSyncService) markSyncRedeployFailedInternal(ctx context.Context, sync *models.GitOpsSync, id, commitHash string, syncedFiles []string, redeployErr redeployAfterSyncFailedError, actor models.User, result *gitops.SyncResult) { + errMsg := redeployErr.Error() + result.Success = false + result.Message = "Sync wrote files but redeploy failed" + result.Error = new(errMsg) + s.updateSyncStatusWithFiles(ctx, id, "failed", errMsg, commitHash, syncedFiles) + s.logSyncError(ctx, sync, actor, errMsg) +} + func (s *GitOpsSyncService) createProjectForSyncInternal(ctx context.Context, sync *models.GitOpsSync, id string, composeContent string, envContent *string, result *gitops.SyncResult, actor models.User) (*models.Project, error) { project, err := s.projectService.CreateProject(ctx, sync.ProjectName, composeContent, envContent, nil, actor) if err != nil { @@ -1108,13 +1375,16 @@ func (s *GitOpsSyncService) updateProjectForSyncInternal(ctx context.Context, sy newCompose, newEnv, _ := s.projectService.GetProjectContent(ctx, project.ID) contentChanged := oldCompose != newCompose || envContentChangedInternal(oldEnv, newEnv) - // If content changed and project is running, redeploy + // If content changed and project is running, redeploy. A redeploy failure + // is bubbled up as a redeployAfterSyncFailedError so the parent flow can + // reflect it on the sync row's LastSyncError. if contentChanged { details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails()) if err == nil && (details.Status == string(models.ProjectStatusRunning) || details.Status == string(models.ProjectStatusPartiallyRunning)) { slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "projectName", project.Name, "projectId", project.ID) if err := s.projectService.RedeployProject(ctx, project.ID, actor, nil); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "error", err, "projectId", project.ID) + return redeployAfterSyncFailedError{cause: err} } } } @@ -1186,6 +1456,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 fac4a20db1..090ff085fd 100644 --- a/backend/internal/services/gitops_sync_service_test.go +++ b/backend/internal/services/gitops_sync_service_test.go @@ -13,6 +13,7 @@ import ( "github.com/getarcaneapp/arcane/backend/v2/internal/models" git "github.com/getarcaneapp/arcane/backend/v2/pkg/gitutil" "github.com/getarcaneapp/arcane/backend/v2/pkg/projects" + "github.com/getarcaneapp/arcane/types/v2/gitops" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -31,7 +32,7 @@ func setupGitOpsSyncDirectoryTestService(t *testing.T) (*GitOpsSyncService, *dat projectsDir := t.TempDir() require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) - projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) return NewGitOpsSyncService(db, nil, projectService, nil, nil, settingsService), db, projectsDir } @@ -665,3 +666,252 @@ func TestGitOpsSyncService_GetEffectiveSyncLimits(t *testing.T) { require.Equal(t, int64(0), maxBinarySize) }) } + +// setupLifecycleValidationService builds a GitOpsSyncService with lifecycle +// hooks enabled in settings so the validator's gate doesn't short-circuit +// the rule checks under test. +func setupLifecycleValidationService(t *testing.T) (*GitOpsSyncService, context.Context) { + t.Helper() + ctx := context.Background() + svc, _, _ := setupGitOpsSyncDirectoryTestService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleEnabled", "true")) + return svc, ctx +} + +func strPtr(s string) *string { return &s } +func intPtr(i int) *int { return &i } +func boolPtr(b bool) *bool { return &b } + +func TestValidateLifecycleConfig_AllNilNoError(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{})) +} + +func TestValidateLifecycleConfig_RejectsWhenGloballyDisabled(t *testing.T) { + svc, _, _ := setupGitOpsSyncDirectoryTestService(t) + ctx := context.Background() + // lifecycleEnabled defaults to false; do not enable. + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "disabled") +} + +func TestValidateLifecycleConfig_RejectsAbsoluteScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("/etc/passwd"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "relative") +} + +func TestValidateLifecycleConfig_RejectsTraversalScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("../outside.sh"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "escape") +} + +func TestValidateLifecycleConfig_RejectsOverlongScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + long := make([]byte, 257) + for i := range long { + long[i] = 'a' + } + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr(string(long)), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "256") +} + +func TestValidateLifecycleConfig_RejectsScriptWithoutRunnerImageOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "Runner image is required") +} + +func TestValidateLifecycleConfig_AcceptsScriptWithExistingRunnerImageOnUpdate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + existing.PreDeployRunnerImage = strPtr("alpine:latest") + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + })) +} + +func TestValidateLifecycleConfig_RejectsTimeoutZeroOrNegative(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + for _, v := range []int{0, -1, -3600} { + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + timeoutSec: intPtr(v), + }) + require.Errorf(t, err, "expected error for timeoutSec=%d", v) + require.Contains(t, err.Error(), "at least 1") + } +} + +func TestValidateLifecycleConfig_RejectsTimeoutAboveSettingCap(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleMaxTimeoutSec", "120")) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + timeoutSec: intPtr(300), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds") +} + +func TestValidateLifecycleConfig_RejectsInvalidEnvKey(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + env: strPtr("FOO-BAR=baz"), + }) + require.Error(t, err) +} + +func TestValidateLifecycleConfig_AcceptsValidEnv(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + env: strPtr("FOO=bar\nBAZ_2=qux"), + })) +} + +func TestValidateLifecycleConfig_RejectsRelativeMountSource(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + extraMounts: strPtr("relative/path:/in/container"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "absolute") +} + +func TestValidateLifecycleConfig_RejectsRelativeMountTarget(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + extraMounts: strPtr("/host/path:relative/target"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "absolute") +} + +func TestValidateLifecycleConfig_AllowsClearingScriptWithoutImage(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{} + existing.PreDeployScriptPath = strPtr("scripts/old.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + // User clears the script (empty string in update); image clear is implied not required. + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr(""), + })) +} + +func TestValidateLifecycleConfig_RejectsScriptWithoutSyncDirectoryOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(false), + }) + require.Error(t, err) + validationErr, ok := errors.AsType[*models.ValidationError](err) + require.True(t, ok, "expected *models.ValidationError, got %T", err) + require.Equal(t, "preDeployScriptPath", validationErr.Field) +} + +func TestValidateLifecycleConfig_AcceptsScriptWithSyncDirectoryOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(true), + })) +} + +func TestValidateLifecycleConfig_AcceptsScriptWhenExistingSyncHasSyncDirectory(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + })) +} + +func TestValidateLifecycleConfig_RejectsSyncDirectoryToggleOffWhileScriptStillSet(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + existing.PreDeployScriptPath = strPtr("scripts/deploy.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + // Admin toggles syncDirectory off without clearing the script — should be rejected. + err := svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + syncDirectory: boolPtr(false), + }) + require.Error(t, err) + validationErr, ok := errors.AsType[*models.ValidationError](err) + require.True(t, ok, "expected *models.ValidationError, got %T", err) + require.Equal(t, "preDeployScriptPath", validationErr.Field) +} + +func TestRedeployAfterSyncFailedError_FormatAndUnwrap(t *testing.T) { + cause := errors.New("pre-deploy hook bombed") + err := redeployAfterSyncFailedError{cause: cause} + + require.Equal(t, "redeploy failed: pre-deploy hook bombed", err.Error()) + require.True(t, errors.Is(err, cause), "Unwrap should expose the cause for errors.Is") + + typed, ok := errors.AsType[redeployAfterSyncFailedError](err) + require.True(t, ok) + require.Equal(t, cause, typed.cause) +} + +func TestMarkSyncRedeployFailedInternal_PersistsErrorOnSyncRow(t *testing.T) { + ctx := context.Background() + svc, db, _ := setupGitOpsSyncDirectoryTestService(t) + // Event logging requires a real EventService; the shared setup leaves it + // nil since most tests don't exercise the event path. + require.NoError(t, db.AutoMigrate(&models.Event{})) + svc.eventService = NewEventService(db, config.Load(), nil) + + sync := &models.GitOpsSync{ + BaseModel: models.BaseModel{ID: "sync-1"}, + Name: "redeploy-fail", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "compose.yml", + TargetType: "project", + } + require.NoError(t, db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(sync).Error) + + result := &gitops.SyncResult{Success: true} + syncedFiles := []string{"compose.yml", "scripts/pre-deploy.sh"} + hookErr := redeployAfterSyncFailedError{cause: errors.New("pre-deploy hook failed: exit 1")} + + svc.markSyncRedeployFailedInternal(ctx, sync, sync.ID, "abc123", syncedFiles, hookErr, models.User{BaseModel: models.BaseModel{ID: "user"}, Username: "tester"}, result) + + require.False(t, result.Success) + require.NotNil(t, result.Error) + require.Contains(t, *result.Error, "pre-deploy hook failed") + + var stored models.GitOpsSync + require.NoError(t, db.WithContext(ctx).First(&stored, "id = ?", sync.ID).Error) + require.NotNil(t, stored.LastSyncStatus) + require.Equal(t, "failed", *stored.LastSyncStatus) + require.NotNil(t, stored.LastSyncError) + require.Contains(t, *stored.LastSyncError, "pre-deploy hook failed") + // The synced-files list should still be populated so operators can see + // what reached disk before the redeploy died. + require.NotNil(t, stored.SyncedFiles) + require.Contains(t, *stored.SyncedFiles, "compose.yml") + require.Contains(t, *stored.SyncedFiles, "scripts/pre-deploy.sh") +} diff --git a/backend/internal/services/lifecycle_service.go b/backend/internal/services/lifecycle_service.go new file mode 100644 index 0000000000..f0b013f252 --- /dev/null +++ b/backend/internal/services/lifecycle_service.go @@ -0,0 +1,727 @@ +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/libbuild" + "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" +) + +// 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 := strings.TrimSpace(utils.DerefString(sync.PreDeployRunnerImage)) + if runnerImage == "" { + return fmt.Errorf("pre-deploy script %q is configured but no runner image is set on the GitOps sync", *sync.PreDeployScriptPath) + } + + scriptPath := strings.TrimSpace(*sync.PreDeployScriptPath) + if err := validateScriptPathInternal(project.Path, scriptPath); err != nil { + return fmt.Errorf("invalid pre-deploy script path: %w", err) + } + + hookEnv, err := parseLifecycleEnvTextInternal(sync.PreDeployEnv) + if err != nil { + return fmt.Errorf("invalid lifecycle env config: %w", err) + } + extraMounts, err := parseLifecycleExtraMountsTextInternal(sync.PreDeployExtraMounts) + if err != nil { + return fmt.Errorf("invalid lifecycle extra mounts config: %w", err) + } + + timeout := s.resolveTimeoutInternal(ctx, sync.PreDeployTimeoutSec) + + slog.InfoContext(ctx, "running pre-deploy lifecycle hook", + "projectID", project.ID, + "syncID", sync.ID, + "scriptPath", scriptPath, + "runnerImage", runnerImage, + "timeoutSec", int(timeout/time.Second), + ) + + start := time.Now() + stdoutContent, stderrContent, exitCode, runErr := s.runScriptInContainerInternal( + ctx, + runnerImage, + project.Path, + scriptPath, + hookEnv, + extraMounts, + sync.PreDeployNetworkMode, + timeout, + ) + durationMs := time.Since(start).Milliseconds() + + status := lifecycleStatusForResultInternal(exitCode, runErr) + persistedOutput := truncateLifecycleOutputInternal(combineLifecycleOutputInternal(stdoutContent, stderrContent), lifecycleMaxOutputBytes) + s.persistLastRunInternal(ctx, sync.ID, status, persistedOutput, start) + s.emitLifecycleEventInternal(ctx, project, sync, status, exitCode, durationMs, runErr, actor) + + if runErr != nil { + return runErr + } + if exitCode != 0 { + return fmt.Errorf("pre-deploy script exited with status %d", exitCode) + } + return nil +} + +// runScriptInContainerInternal performs the docker run + log capture + wait. +// On the happy path it returns stdout, stderr, the script's exit code and a +// nil error. context.DeadlineExceeded from the per-run timeout is wrapped and +// returned as the error so callers can distinguish a timeout from a non-zero +// exit. +func (s *LifecycleService) runScriptInContainerInternal( + ctx context.Context, + runnerImage string, + projectPath string, + scriptPath string, + hookEnv map[string]string, + extraMounts []lifecycleExtraMount, + networkMode string, + timeout time.Duration, +) (stdoutContent string, stderrContent string, exitCode int64, err error) { + dockerClient, dErr := s.dockerService.GetClient(ctx) + if dErr != nil { + return "", "", 0, fmt.Errorf("failed to connect to Docker: %w", dErr) + } + + if err := s.ensureRunnerImageInternal(ctx, dockerClient, runnerImage); err != nil { + return "", "", 0, fmt.Errorf("failed to ensure runner image %s: %w", runnerImage, err) + } + + // Resolve the workspace mount. When Arcane runs inside a container whose + // /app/data is backed by a named volume, a plain bind-mount of the + // translated host path (e.g. /var/lib/docker/volumes/.../_data/projects/X) + // is unreliable — Docker Desktop on WSL2 refuses it outright, and any + // daemon with non-trivial volume storage may behave differently. Using + // the named volume directly with VolumeOptions.Subpath sidesteps the + // translation entirely. For host-bind /app/data the helper returns a + // plain bind that points at the right host subdir. If Arcane is running + // on the host (no container inspect available), fall back to a bind on + // the project path as-is. + workspaceMount, mountErr := dockerutils.MountForCurrentContainerSubpath(ctx, dockerClient, projectPath, lifecycleWorkspaceMount) + if mountErr != nil { + slog.WarnContext(ctx, "failed to derive workspace mount; falling back to bind on project path", "projectPath", projectPath, "error", mountErr) + } + if workspaceMount == nil { + workspaceMount = &mounttypes.Mount{Type: mounttypes.TypeBind, Source: projectPath, Target: lifecycleWorkspaceMount} + } + + // Cmd is the script path alone — no interpreter wrapper. The script's + // shebang selects the interpreter (which must exist in the runner image), + // and the file must be executable on the host (git preserves +x through + // clone, so a committed +x script works transparently). This keeps + // Arcane out of the language-choice business and matches standard + // docker run semantics. + // + // Entrypoint is explicitly cleared because many purpose-built images set + // ENTRYPOINT to their primary tool (e.g. the official getsops/sops image + // has ENTRYPOINT=["sops"]). Without this override, our Cmd would become + // an argument to that tool rather than replacing it. + config := &containertypes.Config{ + Image: runnerImage, + Entrypoint: []string{}, + Cmd: []string{filepath.ToSlash(filepath.Join(lifecycleWorkspaceMount, scriptPath))}, + WorkingDir: lifecycleWorkspaceMount, + Env: envMapToSliceInternal(hookEnv), + AttachStdout: true, + AttachStderr: true, + Tty: false, + Labels: map[string]string{ + libarcane.InternalResourceLabel: "true", + }, + } + // no-new-privileges blocks the script (or anything it spawns) from + // gaining capabilities via setuid binaries inside the runner image. + // CapDrop ALL removes the default capability set Docker grants to root + // in a container (NET_RAW, NET_BIND_SERVICE, SETUID, etc.) — a hook + // script decrypting secrets or generating config has no need for any + // of them, and dropping them takes the most-common privilege-escalation + // primitives off the table even if a malicious script gets in. + hostConfig := &containertypes.HostConfig{ + Mounts: buildLifecycleMountsInternal(workspaceMount, extraMounts), + NetworkMode: resolveLifecycleNetworkModeInternal(networkMode), + SecurityOpt: []string{"no-new-privileges:true"}, + CapDrop: []string{"ALL"}, + AutoRemove: false, + } + + apiTimeoutSec := s.settingsService.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 := libbuild.NewLogCapture(lifecycleMaxOutputBytes) + stderrBuf := libbuild.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, + 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": utils.DerefString(sync.PreDeployRunnerImage), + "exitCode": exitCode, + "durationMs": durationMs, + "gitopsSyncId": sync.ID, + "status": status, + } + + _, err := s.eventService.CreateEvent(ctx, CreateEventRequest{ + Type: models.EventTypeLifecycleExecute, + Severity: severity, + Title: title, + Description: description, + ResourceType: new("project"), + ResourceID: new(project.ID), + ResourceName: new(project.Name), + EnvironmentID: new(sync.EnvironmentID), + 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 truncateLifecycleOutputInternal(content string, maxBytes int) string { + if maxBytes <= 0 || len(content) <= maxBytes { + return content + } + return content[:maxBytes] + "\n..." +} + +func removeLifecycleContainerInternal(ctx context.Context, dockerClient *client.Client, containerID string, apiTimeoutSec int) { + if dockerClient == nil || containerID == "" { + return + } + cleanupCtx := context.WithoutCancel(ctx) + cleanupCtx, cleanupCancel := timeouts.WithTimeout(cleanupCtx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer cleanupCancel() + if _, err := dockerClient.ContainerRemove(cleanupCtx, containerID, client.ContainerRemoveOptions{Force: true}); err != nil && !cerrdefs.IsNotFound(err) { + slog.WarnContext(cleanupCtx, "failed to remove lifecycle container", "containerID", containerID, "error", err) + } +} + +// drainLifecycleLogsInternal waits for the log copy goroutine to finish with +// a bounded deadline, then force-closes the stream. Mirrors the trivy +// pattern, which exists to tolerate Docker variants that don't EOF cleanly +// when a container exits. +func drainLifecycleLogsInternal(ctx context.Context, logsCancel context.CancelFunc, logs io.ReadCloser, logDone <-chan error) { + timer := time.NewTimer(lifecycleStreamDrainTimeout) + defer timer.Stop() + select { + case <-logDone: + case <-timer.C: + slog.DebugContext(ctx, "lifecycle log stream did not close after container exit; force-closing") + } + logsCancel() + _ = logs.Close() +} + +// truncatableCaptureInternal is satisfied by libbuild.LogCapture. 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..2a834d78aa --- /dev/null +++ b/backend/internal/services/lifecycle_service_test.go @@ -0,0 +1,418 @@ +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/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 TestTruncateLifecycleOutput(t *testing.T) { + short := "hello" + assert.Equal(t, short, truncateLifecycleOutputInternal(short, 1024)) + + long := strings.Repeat("a", 200) + got := truncateLifecycleOutputInternal(long, 100) + require.True(t, strings.HasPrefix(got, strings.Repeat("a", 100))) + require.Contains(t, got, "") +} + +func TestRunPreDeploy_NilProject(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + require.NoError(t, svc.RunPreDeploy(context.Background(), nil, 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")) + + 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 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 9ff75539cd..cc54d56f1a 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -48,6 +48,7 @@ type ProjectService struct { imageService *ImageService dockerService *DockerClientService buildService *BuildService + lifecycleService *LifecycleService config *config.Config registryCredentialsProvider registryCredentialsProviderInternal @@ -65,16 +66,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](), } } @@ -2035,15 +2037,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 aee7ac167c..c275988930 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -133,7 +133,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{ @@ -250,7 +250,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{ @@ -362,7 +362,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"}, @@ -378,7 +378,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"}, @@ -394,7 +394,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"}, @@ -410,7 +410,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"}, @@ -447,7 +447,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"}, @@ -588,7 +588,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) @@ -683,7 +683,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", @@ -735,7 +735,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", @@ -798,7 +798,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", @@ -902,7 +902,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", @@ -992,7 +992,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") @@ -1065,7 +1065,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) @@ -1083,7 +1083,7 @@ func TestProjectService_UpdateProject_RenamesDirectoryWhenNameChanges(t *testing require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -1131,7 +1131,7 @@ func TestProjectService_UpdateProject_RenameFailsWhenTargetDirectoryExists(t *te require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -1177,7 +1177,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) @@ -1220,7 +1220,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) @@ -1262,7 +1262,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) @@ -1306,7 +1306,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) @@ -1370,7 +1370,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) @@ -1414,7 +1414,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: @@ -1450,7 +1450,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: @@ -1488,7 +1488,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: @@ -1543,7 +1543,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{ @@ -1585,7 +1585,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"}, @@ -1627,7 +1627,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"}, @@ -1665,7 +1665,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"}, @@ -1705,7 +1705,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) @@ -1748,7 +1748,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) @@ -1794,7 +1794,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) @@ -1840,7 +1840,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) @@ -1879,7 +1879,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) @@ -1925,7 +1925,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) @@ -1971,7 +1971,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) @@ -2015,7 +2015,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) @@ -2063,7 +2063,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) @@ -2106,7 +2106,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) @@ -2152,7 +2152,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) @@ -2201,7 +2201,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) @@ -2252,7 +2252,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) @@ -2300,7 +2300,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) @@ -2349,7 +2349,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) @@ -2393,7 +2393,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) @@ -2440,7 +2440,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) @@ -2478,7 +2478,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) @@ -2605,7 +2605,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)) @@ -2704,7 +2704,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) @@ -2756,7 +2756,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) @@ -2780,7 +2780,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)) @@ -3011,7 +3011,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}, @@ -3063,7 +3063,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 @@ -3093,7 +3093,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)) @@ -3234,7 +3234,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{ @@ -3513,7 +3513,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) @@ -3555,7 +3555,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) @@ -3605,7 +3605,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) @@ -3631,7 +3631,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) @@ -3656,7 +3656,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) @@ -3682,7 +3682,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{ @@ -3716,7 +3716,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) @@ -3763,7 +3763,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{ @@ -3791,7 +3791,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) @@ -3812,7 +3812,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) @@ -3831,7 +3831,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) @@ -3873,7 +3873,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) @@ -3893,7 +3893,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) @@ -3932,7 +3932,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) @@ -3942,7 +3942,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) @@ -3975,7 +3975,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) @@ -4010,7 +4010,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 @@ -4041,7 +4041,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{ @@ -4072,7 +4072,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) @@ -4102,7 +4102,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 @@ -4156,7 +4156,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) @@ -4206,7 +4206,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) @@ -4238,7 +4238,7 @@ func TestProjectService_UpdateProject_WritesThroughSymlinkedProjectPath(t *testi require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) project := &models.Project{ BaseModel: models.BaseModel{ID: "proj-symlink-update"}, diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index 26c4dbb36a..0244647663 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -193,6 +193,8 @@ 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"}, + LifecycleMaxTimeoutSec: models.SettingVariable{Value: "300"}, DockerAPITimeout: models.SettingVariable{Value: "30"}, DockerImagePullTimeout: models.SettingVariable{Value: "600"}, 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/dockerutil/mount_utils.go b/backend/pkg/dockerutil/mount_utils.go index e080f601ae..61f273fc3e 100644 --- a/backend/pkg/dockerutil/mount_utils.go +++ b/backend/pkg/dockerutil/mount_utils.go @@ -85,6 +85,113 @@ func getCurrentContainerInspectTargetInternal(currentContainerID func() (string, return strings.TrimSpace(value), nil } +// MountForCurrentContainerSubpath inspects the current container, finds the +// existing mount whose destination covers containerPath, and returns a Mount +// suitable for use in another container creation that exposes the same data +// at target. Returns nil + no error if Arcane isn't running inside a +// container or no suitable mount is found — callers can fall back to a +// plain bind on containerPath in that case. +func MountForCurrentContainerSubpath(ctx context.Context, dockerCli *client.Client, containerPath, target string) (*mounttypes.Mount, error) { + if dockerCli == nil { + return nil, nil + } + inspectTarget, err := getCurrentContainerInspectTargetInternal(GetCurrentContainerID, os.Hostname) + if err != nil { + return nil, err + } + inspect, err := libarcane.ContainerInspectWithCompatibility(ctx, dockerCli, inspectTarget, client.ContainerInspectOptions{}) + if err != nil { + return nil, err + } + return MountForSubpath(inspect.Container.Mounts, containerPath, target), nil +} + +// MountForSubpath returns a Mount that exposes a subpath of one of the +// current container's existing mounts at the requested target. It's a +// generalisation of MountForDestination for the case where the caller +// wants a sub-tree below an existing mount destination (e.g. +// "/app/data/projects/X" when "/app/data" is what the container has +// mounted). +// +// The function picks the most-specific mount whose Destination is a +// prefix of containerPath, then constructs the Mount based on the +// backing type: +// +// - TypeBind: Source = mount.Source joined with the relative subpath. +// Works because bind sources are real host paths the daemon +// can address directly. +// - TypeVolume: Source = mount.Name (the volume name), and the relative +// subpath is set on VolumeOptions.Subpath. This lets the +// daemon mount the named volume directly without needing a +// host-side path translation — important for setups where +// the underlying volume storage is opaque (Docker Desktop +// on WSL2, Docker-in-Docker, etc.). +// +// Returns nil if no mount destination is a prefix of containerPath or if +// the matching mount is of an unsupported type. +func MountForSubpath(mounts []containertypes.MountPoint, containerPath string, target string) *mounttypes.Mount { + if strings.TrimSpace(containerPath) == "" { + return nil + } + if strings.TrimSpace(target) == "" { + target = containerPath + } + + var best *containertypes.MountPoint + for i := range mounts { + m := &mounts[i] + if m.Destination == "" { + continue + } + if !pathHasPrefix(containerPath, m.Destination) { + continue + } + if best == nil || len(m.Destination) > len(best.Destination) { + best = m + } + } + if best == nil { + return nil + } + + relative := strings.TrimPrefix(strings.TrimPrefix(containerPath, best.Destination), "/") + readOnly := !best.RW + + switch best.Type { //nolint:exhaustive // only bind and volume mounts are translatable; the default returns nil for the rest + case mounttypes.TypeBind: + if strings.TrimSpace(best.Source) == "" { + return nil + } + source := best.Source + if relative != "" { + source = strings.TrimRight(source, "/") + "/" + relative + } + return &mounttypes.Mount{Type: mounttypes.TypeBind, Source: source, Target: target, ReadOnly: readOnly} + case mounttypes.TypeVolume: + if strings.TrimSpace(best.Name) == "" { + return nil + } + m := &mounttypes.Mount{Type: mounttypes.TypeVolume, Source: best.Name, Target: target, ReadOnly: readOnly} + if relative != "" { + m.VolumeOptions = &mounttypes.VolumeOptions{Subpath: relative} + } + return m + default: + return nil + } +} + +// pathHasPrefix reports whether containerPath is at or under prefix, +// treating both as POSIX-style paths. Avoids false positives like +// "/app/datax" matching "/app/data". +func pathHasPrefix(containerPath, prefix string) bool { + if containerPath == prefix { + return true + } + p := strings.TrimRight(prefix, "/") + "/" + return strings.HasPrefix(containerPath, p) +} + // MountForDestination returns a Mount suitable for container creation that mirrors an // existing container mount at the given destination. // diff --git a/backend/pkg/dockerutil/mount_utils_test.go b/backend/pkg/dockerutil/mount_utils_test.go index a4eb6a472c..3e39b023d5 100644 --- a/backend/pkg/dockerutil/mount_utils_test.go +++ b/backend/pkg/dockerutil/mount_utils_test.go @@ -125,3 +125,85 @@ func TestMountForDestination(t *testing.T) { }) } } + +func TestMountForSubpath(t *testing.T) { + hostBind := containertypes.MountPoint{Type: mounttypes.TypeBind, Source: "/host/projects-root", Destination: "/app/data", RW: true} + namedVolume := containertypes.MountPoint{Type: mounttypes.TypeVolume, Name: "arcane-dev-data", Destination: "/app/data", RW: true} + nestedBind := containertypes.MountPoint{Type: mounttypes.TypeBind, Source: "/host/projects-only", Destination: "/app/data/projects", RW: true} + readOnlyVolume := containertypes.MountPoint{Type: mounttypes.TypeVolume, Name: "ro-vol", Destination: "/app/data", RW: false} + tmpfsMount := containertypes.MountPoint{Type: mounttypes.TypeTmpfs, Destination: "/app/data"} + + t.Run("bind mount with subpath joins host source", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeBind, got.Type) + require.Equal(t, "/host/projects-root/projects/foo", got.Source) + require.Equal(t, "/workspace", got.Target) + require.False(t, got.ReadOnly) + require.Nil(t, got.VolumeOptions) + }) + + t.Run("named volume with subpath uses VolumeOptions.Subpath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{namedVolume}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeVolume, got.Type) + require.Equal(t, "arcane-dev-data", got.Source) + require.Equal(t, "/workspace", got.Target) + require.NotNil(t, got.VolumeOptions) + require.Equal(t, "projects/foo", got.VolumeOptions.Subpath) + }) + + t.Run("picks the most-specific matching mount", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind, nestedBind}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + // nestedBind's destination is more specific, so the relative subpath is "foo" + require.Equal(t, "/host/projects-only/foo", got.Source) + }) + + t.Run("exact-destination match has no subpath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{namedVolume}, "/app/data", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeVolume, got.Type) + require.Equal(t, "arcane-dev-data", got.Source) + require.Nil(t, got.VolumeOptions, "exact destination match shouldn't add VolumeOptions") + }) + + t.Run("preserves read-only", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{readOnlyVolume}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.True(t, got.ReadOnly) + }) + + t.Run("defaults target to containerPath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/data/projects/foo", "") + require.NotNil(t, got) + require.Equal(t, "/app/data/projects/foo", got.Target) + }) + + t.Run("rejects unsupported mount types", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{tmpfsMount}, "/app/data/projects/foo", "/workspace")) + }) + + t.Run("returns nil when no mount destination is a prefix", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "/elsewhere/foo", "/workspace")) + }) + + t.Run("does not match similar-looking destinations", func(t *testing.T) { + // "/app/datax" must not match "/app/data". + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/datax/foo", "/workspace")) + }) + + t.Run("rejects empty containerPath", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "", "/workspace")) + }) + + t.Run("rejects bind mount with empty source", func(t *testing.T) { + bad := containertypes.MountPoint{Type: mounttypes.TypeBind, Destination: "/app/data"} + require.Nil(t, MountForSubpath([]containertypes.MountPoint{bad}, "/app/data/projects/foo", "/workspace")) + }) + + t.Run("rejects named volume with empty name", func(t *testing.T) { + bad := containertypes.MountPoint{Type: mounttypes.TypeVolume, Destination: "/app/data"} + require.Nil(t, MountForSubpath([]containertypes.MountPoint{bad}, "/app/data/projects/foo", "/workspace")) + }) +} diff --git a/backend/pkg/gitutil/git.go b/backend/pkg/gitutil/git.go index 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/061_add_gitops_sync_pre_deploy_hook.sql b/backend/resources/migrations/postgres/061_add_gitops_sync_pre_deploy_hook.sql new file mode 100644 index 0000000000..57c445a3b9 --- /dev/null +++ b/backend/resources/migrations/postgres/061_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/061_add_gitops_sync_pre_deploy_hook.sql b/backend/resources/migrations/sqlite/061_add_gitops_sync_pre_deploy_hook.sql new file mode 100644 index 0000000000..abc6fc3078 --- /dev/null +++ b/backend/resources/migrations/sqlite/061_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/types/gitops/gitops.go b/types/gitops/gitops.go index 011f5da7fe..e10ac33bd8 100644 --- a/types/gitops/gitops.go +++ b/types/gitops/gitops.go @@ -168,6 +168,64 @@ type GitOpsSync struct { // Required: false LastSyncCommit *string `json:"lastSyncCommit,omitempty"` + // PreDeployScriptPath is the optional path inside the synced repo to a + // script executed in a throwaway container before each deploy. Scripts + // are repo-trusted code; the configured runner image, env, and mounts + // shape the runtime context (admin-managed, never from repo data). + // + // Required: false + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty"` + + // PreDeployRunnerImage is the image used to run the pre-deploy script. + // Required whenever PreDeployScriptPath is set. + // + // Required: false + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty"` + + // PreDeployEnv is the KEY=VALUE env config exposed to the script, one + // entry per line; same format as a .env file. + // + // Required: false + PreDeployEnv *string `json:"preDeployEnv,omitempty"` + + // PreDeployExtraMounts is the bind-mount config added to the runner + // container, one entry per line in docker -v "src:tgt[:ro|:rw]" form. + // + // Required: false + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty"` + + // PreDeployTimeoutSec bounds the script execution. Capped by the + // lifecycleMaxTimeoutSec global setting at run time. + // + // Required: true + PreDeployTimeoutSec int `json:"preDeployTimeoutSec"` + + // PreDeployNetworkMode is the Docker network mode passed to the runner + // container. Defaults to "none" so scripts run with no network access + // unless explicitly opted in. Set to "bridge", "host", or a named + // network when the script needs outbound or compose-network access. + // + // Required: true + PreDeployNetworkMode string `json:"preDeployNetworkMode"` + + // PreDeployLastRunAt is the timestamp of the most recent pre-deploy + // lifecycle hook run on this sync. + // + // Required: false + PreDeployLastRunAt *time.Time `json:"preDeployLastRunAt,omitempty"` + + // PreDeployLastRunStatus is the status of the most recent pre-deploy + // lifecycle hook run: "success", "failed", or "timeout". + // + // Required: false + PreDeployLastRunStatus *string `json:"preDeployLastRunStatus,omitempty"` + + // PreDeployLastRunOutput is the truncated combined stdout+stderr from + // the most recent pre-deploy lifecycle hook run. + // + // Required: false + PreDeployLastRunOutput *string `json:"preDeployLastRunOutput,omitempty"` + // CreatedAt is the date and time at which the sync was created. // // Required: true @@ -367,6 +425,47 @@ type CreateSyncRequest struct { // // Required: false MaxSyncBinarySize *int64 `json:"maxSyncBinarySize,omitempty"` + + // PreDeployScriptPath is the optional path inside the synced repo to a + // script executed in a throwaway container before each deploy. When set, + // PreDeployRunnerImage must also be supplied. + // + // Required: false + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty"` + + // PreDeployRunnerImage is the image used to run the pre-deploy script. + // Required whenever PreDeployScriptPath is set. + // + // Required: false + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty"` + + // PreDeployEnv is the env config exposed to the script, one KEY=VALUE + // entry per line; same format as a .env file. Keys must match POSIX + // identifier syntax. + // + // Required: false + PreDeployEnv *string `json:"preDeployEnv,omitempty"` + + // PreDeployExtraMounts is the bind-mount config added to the runner + // container, one entry per line in docker -v "src:tgt[:ro|:rw]" form. + // Source and target must be absolute paths. + // + // Required: false + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty"` + + // PreDeployTimeoutSec bounds the script execution. Capped by the + // lifecycleMaxTimeoutSec global setting at validation time. Defaults to 60. + // + // Required: false + PreDeployTimeoutSec *int `json:"preDeployTimeoutSec,omitempty"` + + // PreDeployNetworkMode is the Docker network mode for the runner + // container. Defaults to "none" (no network access). Set to "bridge", + // "host", or a named Docker network to grant outbound or compose-network + // access. + // + // Required: false + PreDeployNetworkMode *string `json:"preDeployNetworkMode,omitempty"` } // UpdateSyncRequest represents the request to update a gitops sync. @@ -435,6 +534,46 @@ type UpdateSyncRequest struct { // // Required: false MaxSyncBinarySize *int64 `json:"maxSyncBinarySize,omitempty"` + + // PreDeployScriptPath is the optional path inside the synced repo to a + // script executed in a throwaway container before each deploy. Set to + // an empty string to clear an existing configuration. + // + // Required: false + PreDeployScriptPath *string `json:"preDeployScriptPath,omitempty"` + + // PreDeployRunnerImage is the image used to run the pre-deploy script. + // Required whenever PreDeployScriptPath is non-empty. + // + // Required: false + PreDeployRunnerImage *string `json:"preDeployRunnerImage,omitempty"` + + // PreDeployEnv is the env config exposed to the script, one KEY=VALUE + // entry per line; same format as a .env file. Keys must match POSIX + // identifier syntax. + // + // Required: false + PreDeployEnv *string `json:"preDeployEnv,omitempty"` + + // PreDeployExtraMounts is the bind-mount config added to the runner + // container, one entry per line in docker -v "src:tgt[:ro|:rw]" form. + // Source and target must be absolute paths. + // + // Required: false + PreDeployExtraMounts *string `json:"preDeployExtraMounts,omitempty"` + + // PreDeployTimeoutSec bounds the script execution. Capped by the + // lifecycleMaxTimeoutSec global setting at validation time. + // + // Required: false + PreDeployTimeoutSec *int `json:"preDeployTimeoutSec,omitempty"` + + // PreDeployNetworkMode is the Docker network mode for the runner + // container. Set to "none", "bridge", "host", or a named Docker + // network. Empty string resets to the default ("none"). + // + // Required: false + PreDeployNetworkMode *string `json:"preDeployNetworkMode,omitempty"` } // SyncResult represents the result of a sync operation. diff --git a/types/settings/settings.go b/types/settings/settings.go index cb6d66fb41..44f20958bd 100644 --- a/types/settings/settings.go +++ b/types/settings/settings.go @@ -197,6 +197,19 @@ type Update struct { // Required: false GitSyncMaxBinarySizeMb *string `json:"gitSyncMaxBinarySizeMb,omitempty"` + // LifecycleEnabled gates whether GitOps syncs may configure pre-deploy + // lifecycle scripts. Disabled by default because scripts are repo-trusted + // code that runs on every deploy. + // + // Required: false + LifecycleEnabled *string `json:"lifecycleEnabled,omitempty"` + + // LifecycleMaxTimeoutSec caps the per-sync pre-deploy timeout admins can + // configure. Zero disables the cap. + // + // Required: false + LifecycleMaxTimeoutSec *string `json:"lifecycleMaxTimeoutSec,omitempty"` + // BaseServerURL is the base URL of the server. // // Required: false From 9689a57f5348165889cae81e5709a78621be81f9 Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Mon, 1 Jun 2026 11:38:50 +1000 Subject: [PATCH 03/12] feat(lifecycle): pre-deploy hook UI for gitops syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the lifecycle hook backend through the GitOps sync dialog, project view, and surrounding surfaces. Add/edit sync dialog (gitops-sync-dialog.svelte) - New collapsible "Pre-deploy script" section with an acknowledgement banner (this is repo-trusted code that runs on every deploy), script path with file-browser picker, runner image, timeout, network, env vars (KEY=VALUE per line), and extra bind mounts. - File-browser opens scoped to the compose-dir so the user can only pick files that will actually exist in the deploy-time workspace. - Hard-rejects "Sync entire directory" being off while a script is configured (the validator returns a field-attributed error surfaced via the toast description). File browser (file-browser-dialog.svelte) - Generalised away from "compose-only": accepts a fileFilter predicate, a fileBadge Snippet, a footerHint Snippet, and a rootPath that scopes the browse subtree (blocks navigation upward, paths returned relative to the root). Compose-mode and script-mode are now just call-site configurations. Title + description are also overridable. Validation toast (gitops/+page.svelte) - Splits create/update failures into title + description so the field-attributed validator messages now reach the description line. Project view (projects/[projectId]/+page.svelte) - Adds a single inline line on the existing git-managed banner: `Pre-deploy script: (image: ..., network: ...)` — rendered only when a hook is configured. - Classic-layout file-tab row surfaces the script alongside compose includes; clicking it renders the script content read-only. Sync table (sync-table.svelte) - LifecycleIndicator component (CodeIcon + tooltip with path) marks rows that have a hook configured. Form input refactor (form/form-input.svelte) - Adds an `inputClass` prop forwarded to the underlying Input / Textarea so the new env-vars / extra-mounts textareas can use font-mono for their code-like content without losing the label/helpText/error wiring FormInput already provides. i18n (messages/en.json) and types (lib/types/gitops.type.ts, query/query-keys.ts) plumbed for the above. --- frontend/messages/en.json | 23 +++ .../dialogs/file-browser-dialog.svelte | 114 +++++++----- .../dialogs/gitops-sync-dialog.svelte | 167 +++++++++++++++++- .../src/lib/components/form/form-input.svelte | 8 +- .../components/lifecycle-indicator/index.ts | 1 + .../lifecycle-indicator.svelte | 28 +++ frontend/src/lib/query/query-keys.ts | 3 +- frontend/src/lib/types/automation.ts | 21 +++ .../environments/[id]/gitops/+page.svelte | 8 +- .../[id]/gitops/sync-table.svelte | 18 +- .../(app)/projects/[projectId]/+page.svelte | 92 ++++++++-- 11 files changed, 406 insertions(+), 77 deletions(-) create mode 100644 frontend/src/lib/components/lifecycle-indicator/index.ts create mode 100644 frontend/src/lib/components/lifecycle-indicator/lifecycle-indicator.svelte diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d93dc1e443..020b194d05 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1896,6 +1896,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", @@ -1922,6 +1923,28 @@ "git_sync_max_files_per_sync_help": "Maximum files for this sync. Set 0 for no cap.", "git_sync_max_total_size_per_sync_help": "Maximum combined size for this sync. Set 0 for no cap.", "git_sync_max_binary_size_per_sync_help": "Maximum binary file size for this sync. Set 0 for no cap.", + "git_sync_pre_deploy_title": "Pre-deploy script", + "git_sync_pre_deploy_description": "Run a script from this repo before each deploy. Useful for decrypting secrets, generating config, or preparing the deploy environment.", + "git_sync_pre_deploy_script_path_label": "Script path", + "git_sync_pre_deploy_script_path_placeholder": "scripts/pre-deploy.sh", + "git_sync_pre_deploy_script_path_help": "Path within the synced directory (i.e. next to your compose file). The script's first line picks the interpreter.", + "git_sync_pre_deploy_runner_image_label": "Runner image", + "git_sync_pre_deploy_runner_image_placeholder": "alpine:latest", + "git_sync_pre_deploy_runner_image_help": "Docker image used to run the script. Required when a script path is set.", + "git_sync_pre_deploy_timeout_label": "Timeout (seconds)", + "git_sync_pre_deploy_timeout_help": "Maximum time the script is allowed to run.", + "git_sync_pre_deploy_network_mode_label": "Network", + "git_sync_pre_deploy_network_mode_placeholder": "none", + "git_sync_pre_deploy_network_mode_help": "Network the script can reach. Default \"none\" blocks all network access. Use \"bridge\" for outbound internet, or a Docker network name.", + "git_sync_pre_deploy_env_label": "Environment variables", + "git_sync_pre_deploy_env_placeholder": "# Example:\nFOO=bar\nDB_HOST=db.internal", + "git_sync_pre_deploy_env_help": "Env vars the script can read, one KEY=VALUE per line. Same format as a .env file.", + "git_sync_pre_deploy_extra_mounts_label": "Extra mounts", + "git_sync_pre_deploy_extra_mounts_placeholder": "# Example:\n/host/path:/container/path:ro", + "git_sync_pre_deploy_extra_mounts_help": "Host bind mounts added to the runner container, one per line in host_path:container_path[:ro|:rw] form.", + "git_sync_pre_deploy_acknowledgement": "Arcane runs this script on every deploy. Anyone who can push to the repo can change what it does.", + "lifecycle_indicator_tooltip": "Runs a pre-deploy script: {path}", + "lifecycle_inline_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..86b1754789 100644 --- a/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte +++ b/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte @@ -10,7 +10,7 @@ import { Switch } from '$lib/components/ui/switch/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import FileBrowserDialog from '$lib/components/dialogs/file-browser-dialog.svelte'; - import type { GitOpsSync, GitOpsSyncCreateDto, GitOpsSyncUpdateDto, GitRepository, BranchInfo } from '$lib/types/automation'; + import type { FileTreeNode, GitOpsSync, GitOpsSyncCreateDto, GitOpsSyncUpdateDto, GitRepository, BranchInfo } from '$lib/types/automation'; import { gitRepositoryService } from '$lib/services/git-repository-service'; import { settingsService } from '$lib/services/settings-service'; import { z } from 'zod/v4'; @@ -35,6 +35,10 @@ let isEditMode = $derived(!!syncToEdit); let showFileBrowser = $state(false); + let fileBrowserTarget = $state<'compose' | 'preDeployScript'>('compose'); + + const composeFileFilter = (file: FileTreeNode) => + file.type === 'file' && (file.name.endsWith('.yml') || file.name.endsWith('.yaml')); let selectedTargetType = $state('project'); const targetTypeOptions = [ @@ -56,7 +60,13 @@ maxSyncTotalSizeMb: z.coerce.number().int().nonnegative(), maxSyncBinarySizeMb: z.coerce.number().int().nonnegative(), autoSync: z.boolean().default(true), - syncInterval: z.number().min(1).default(5) + syncInterval: z.number().min(1).default(5), + preDeployScriptPath: z.string().default(''), + preDeployRunnerImage: z.string().default(''), + preDeployTimeoutSec: z.coerce.number().int().positive().default(60), + preDeployNetworkMode: z.string().default('none'), + preDeployEnv: z.string().default(''), + preDeployExtraMounts: z.string().default('') }); const bytesPerMegabyte = 1024 * 1024; @@ -94,7 +104,13 @@ ? bytesToMegabytesInternal(syncToEdit.maxSyncBinarySize, 0) : (settingsQuery.data?.gitSyncMaxBinarySizeMb ?? 0), autoSync: open && syncToEdit ? (syncToEdit.autoSync ?? true) : true, - syncInterval: open && syncToEdit ? (syncToEdit.syncInterval ?? 5) : 5 + syncInterval: open && syncToEdit ? (syncToEdit.syncInterval ?? 5) : 5, + preDeployScriptPath: open && syncToEdit ? (syncToEdit.preDeployScriptPath ?? '') : '', + preDeployRunnerImage: open && syncToEdit ? (syncToEdit.preDeployRunnerImage ?? '') : '', + preDeployTimeoutSec: open && syncToEdit ? (syncToEdit.preDeployTimeoutSec ?? 60) : 60, + preDeployNetworkMode: open && syncToEdit ? (syncToEdit.preDeployNetworkMode ?? 'none') : 'none', + preDeployEnv: open && syncToEdit ? (syncToEdit.preDeployEnv ?? '') : '', + preDeployExtraMounts: open && syncToEdit ? (syncToEdit.preDeployExtraMounts ?? '') : '' }); let { inputs, ...form } = $derived(createForm(formSchema, formData)); @@ -163,7 +179,13 @@ maxSyncTotalSize: megabytesToBytesInternal(data.maxSyncTotalSizeMb), maxSyncBinarySize: megabytesToBytesInternal(data.maxSyncBinarySizeMb), autoSync: data.autoSync, - syncInterval: data.syncInterval + syncInterval: data.syncInterval, + preDeployScriptPath: data.preDeployScriptPath.trim(), + preDeployRunnerImage: data.preDeployRunnerImage.trim(), + preDeployTimeoutSec: data.preDeployTimeoutSec, + preDeployNetworkMode: data.preDeployNetworkMode.trim(), + preDeployEnv: data.preDeployEnv.trim(), + preDeployExtraMounts: data.preDeployExtraMounts.trim() }; onSubmit({ sync: payload, isEditMode }); @@ -285,7 +307,10 @@ type="button" variant="outline" size="icon" - onclick={() => (showFileBrowser = true)} + onclick={() => { + fileBrowserTarget = 'compose'; + showFileBrowser = true; + }} disabled={!selectedRepository?.value || !$inputs.branch.value} title={m.git_sync_browse_files_title()} > @@ -380,6 +405,109 @@ + + + + {m.git_sync_pre_deploy_title()} + + + + +
+ + + + {m.git_sync_pre_deploy_acknowledgement()} + + + +

{m.git_sync_pre_deploy_description()}

+ +
+ +
+
+ +
+ +
+

{m.git_sync_pre_deploy_script_path_help()}

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

{$inputs.preDeployScriptPath.error}

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

{m.git_sync_browse_hint()}

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