feat: add pre-deploy hook for GitOps project syncs#3022
Merged
Conversation
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.
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.
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: <path> (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.
currentString and pathHasPrefix were the two new unexported helpers that missed the repo-wide naming convention. Rename to currentStringInternal and pathHasPrefixInternal. Greptile review on getarcaneapp#2782.
The if/else after a non-nil error did `return result, err` in both branches; only the side-effect (markSyncRedeployFailedInternal) was gated on the AsType check. Flatten to do the gated side-effect and return once. For the directory-sync path: the source (redeployIfRunningAfterSync) only ever returns nil or redeployAfterSyncFailedError, so the AsType check is effectively guaranteed today but is left as a defensive guard against future contract drift. For the single-file path: the source (getOrCreateProjectInternal) can return either the typed error or a plain failSync error, so the AsType check is meaningful — but the duplicate return still wasn't. Greptile review on getarcaneapp#2782.
stdoutBuf and stderrBuf are each lifecycleCappedBuffer-backed and already append "\n...<truncated>" when they hit lifecycleMaxOutputBytes, so the combined string is already bounded. The outer truncateLifecycleOutputInternal call sliced at a fixed byte boundary, which could land mid-UTF-8 codepoint and corrupt the persisted output. Drop the call (and the now-unused helper + its test); rely on the buffers. The combined ceiling is now ~2× lifecycleMaxOutputBytes (stdout + separator + stderr) which is fine — pre_deploy_last_run_output is TEXT in both Postgres and SQLite. Greptile review on getarcaneapp#2782.
The pre-deploy lifecycle hook runs an arbitrary container (with host
bind mounts, env, and network access) on every sync, so configuring it
is effectively arbitrary code execution on the host. It was reachable by
anyone holding gitops:create / gitops:update -- which the built-in
Editor role (and any custom role) can hold -- so a non-admin could
author a hook.
Add a dedicated env-scoped gitops:lifecycle permission, seeded only into
the Admin built-in role. Admin re-syncs to AllPermissions() on boot via
EnsureBuiltInRoles, so no migration is needed and existing custom roles
do not silently gain it. Editors keep gitops:create/update for normal
syncs but can no longer author hooks unless explicitly granted
gitops:lifecycle.
Backend
- New PermGitOpsLifecycle constant + catalog entry under GitOps Syncs.
- CreateSync / UpdateSync reject requests that configure the hook unless
the caller holds gitops:lifecycle for the target env (403). Whether a
request touches the hook is decided by the request type itself
(gitops.*SyncRequest.HasPreDeployConfig), so the lifecycle field set
has a single owner next to the fields rather than being re-derived in
the handler.
Frontend
- The dialog gates the editable pre-deploy section on
hasPermission('gitops:lifecycle', environmentId). Users without it see
a read-only summary (styled like the collapsed section) that still
flags whether a hook is configured, and never submit the hook fields
-- so editing an existing sync leaves the hook untouched.
- The "Sync files" toggle is locked for those users when a hook is
configured, so they cannot accidentally invalidate it (the validator
requires directory sync while a script is set).
Per review, the locally-defined redeployAfterSyncFailedError belongs with the rest of the package error types. Promote it to an exported common.RedeployAfterSyncFailedError matching the file's convention (pointer receiver, exported Err field, Unwrap), and update the GitOps sync flow and its test to construct and type-assert the moved type. No behavioural change: the error still wraps the redeploy cause and is surfaced on the sync row's LastSyncError.
Contributor
Author
|
Sorry this took so long @kmendell ... I lost your earlier message somehow 🙂 |
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can stay up-to-date and reviewed. |
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can stay up-to-date and reviewed. |
kmendell
approved these changes
Jun 28, 2026
github-actions Bot
added a commit
to ShobuPrime/home-assistant-apps
that referenced
this pull request
Jun 29, 2026
## Arcane Docker Manager Update This automated PR updates Arcane from `2.1.0` to `2.2.0`. ### Changelog ### New features * system mode for light/dark mode ([#2994](getarcaneapp/arcane#2994) by `kmendell`) * allow use of remote trivy server, and only show fixable cves ([#2999](getarcaneapp/arcane#2999) by `kmendell`) * preserve managed volumes on project rename ([#2919](getarcaneapp/arcane#2919) by `NeurekaSoftware`) * show attestations panel for images when supported ([#3036](getarcaneapp/arcane#3036) by `kmendell`) * add missing kill/pause container actions ([#3037](getarcaneapp/arcane#3037) by `kmendell`) * add image history, tagging, registry search and local comitting ([#3039](getarcaneapp/arcane#3039) by `kmendell`) * allow custom profile pictures ([#3023](getarcaneapp/arcane#3023) by `OlziYT`) * add pre-deploy hook for GitOps project syncs ([#3022](getarcaneapp/arcane#3022) by `peitschie`) ### Bug ### Changes - Updated `config.yaml` version - Updated `build.yaml` ARCANE_VERSION - Updated `Dockerfile` ARCANE_VERSION - Updated documentation files - Updated CHANGELOG.md ### Release Notes Full release notes: https://github.com/getarcaneapp/arcane/releases/tag/v2.2.0 --- This PR was automatically generated by the Update Arcane workflow Auto-merged by GitHub Actions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Re-raise of #2782
Fixes: #2249
Checklist
mainbranchWhat This PR Implements
Adds an opt-in pre-deploy lifecycle hook to GitOps syncs: a script committed in the repo runs in a throwaway container immediately before the linked project deploys. Canonical use case is decrypting/generating files compose then consumes (
sops -d secrets.enc.env > .env.runtimefor anenv_file: .env.runtimeservice) without Arcane-specific machinery in the loop.THIS FEATURE IS DISABLED BY DEFAULT. There's currently no UI for
category=securitysettings (the Security category is hidden insettings/+page.svelte), so admins enable viaLIFECYCLE_ENABLED=true(env override) orPUT /api/environments/{id}/settings.Addresses: #1707
Changes Made
feat(lifecycle): pre-deploy hook backend ...): migration 053 + LifecycleService +MountForSubpathhelper. Runner is hardened (no-new-privileges,CapDrop ALL, defaultnetwork=none, workspace-only mount, configurable timeout) and gated behind alifecycleEnabledsetting (default off,envOverride). Walker preserves git's+xbit so committed scripts arrive runnable. Validator returns*models.ValidationErrorwith field attribution; GitOps error wrappers now surface inner errors via%vso dialog toasts show the actual reason. Mount strategy detects whether/app/datais a host bind or a named volume and picksTypeBind-with-joined-source orTypeVolume+VolumeOptions.Subpathaccordingly.feat(lifecycle): pre-deploy hook UI ...): new "Pre-deploy script" collapsible in the sync dialog (acknowledgement banner, file-browser picker scoped to the compose-dir, runner image, timeout, network, env vars, extra mounts). File-browser generalised away from compose-only viafileFilter/fileBadge/footerHintsnippets +rootPath. Lifecycle indicator on sync-table rows; inline summary on the project view's git-managed banner; script viewable read-only in the classic-layout file-tab row. Validation toasts now split title + field-attributed description..gitattributesfor husky + Justfile +*.shLF enforcement (the only things that actually break under CRLF).Testing Done
./scripts/development/dev.sh start.env.runtime, composeenv_file:reads it.gofmt -lclean,go vetclean,pnpm check0 errors / 0 warningsTestProjectService_ListProjects_WithDerivedStatusFilter_AllowsAllPageSizeSentinel) fails on this PR and on a cleanupstream/mainworktree — not introduced here.If useful, I have two local repos I can publish:
arcane-lifecycle-examples: seven example projects exercising the feature matrix (baseline, success, failure, timeout, write-env-file, extra mount, needs-network), served viagit daemonfor local sync testing.arcane-playwright-tests: the 20-scenario harness mentioned above, with an HTML report + screenshots.AI Tool Used (if applicable)
AI Tool: Claude Code (Claude Opus 4.7)
Assistance Level: Significant
What AI helped with: design exploration (discussed scope, threat model, mode-B vs mode-C runner choice), code writing across backend/frontend/tests, iterative refactoring in response to my review notes, parallel-agent code review pass before submission.
I reviewed and edited all AI-generated output: yes — drove design choices (kill switch shape, capture-env removal, file-browser slot/filter refactor, single inline banner over a standalone Collapsible), reviewed UI shape and prompted tightening when output felt bloated.
I ran all required tests and manually verified changes: yes — drove the dev stack, exercised every dialog state in the browser, kicked off real deploys to observe hook outcomes (status, output, on-disk artifacts), ran the e2e suite after each round of changes.
Screenshots
Disclaimer Greptiles Reviews use AI, make sure to check over its work.
To better help train Greptile on our codebase, if the comment is useful and valid Like the comment, if its not helpful or invalid Dislike
To have Greptile Re-Review the changes, mention
greptileai.Greptile Summary
This PR adds an opt-in pre-deploy lifecycle hook to GitOps syncs: a repo-committed script runs in a hardened throwaway container immediately before each deploy, enabling patterns like SOPS secret decryption before Compose reads an
env_file. The feature is disabled by default and gated behind a newgitops:lifecyclepermission (Admin-role only).gitops_syncs;LifecycleServiceruns the hook withno-new-privileges,CapDrop ALL, workspace-only bind mount, and a configurable network mode (defaultnone);GitOpsSyncServiceadds lifecycle validation and field-apply helpers;redeployIfRunningAfterSyncnow returns an error that is surfaced on the sync row'sLastSyncError; git walker andfs_writerpropagate the source's+xbit so committed scripts arrive executable.LifecycleIndicatorin the sync table, lifecycle summary on the project view; file-browser dialog generalised from compose-only to slot-basedfileFilter/fileBadge/footerHint..gitattributesfor LF enforcement on scripts and Justfile.Confidence Score: 5/5
The PR is safe to merge. The lifecycle runner is well-hardened and the permission boundary is correctly enforced at both frontend and backend.
The implementation is solid throughout — container hardening, permission gating, exec-bit propagation, and the redeploy-failure error surface are all well handled. The one finding is a minor runtime/validation mismatch in the runner-image fallback that requires a deliberate sequence of admin actions to trigger and produces an observable rather than silent outcome.
backend/internal/services/lifecycle_service.go (resolveRunnerImageInternal fallback)
Reviews (4): Last reviewed commit: "cr" | Re-trigger Greptile