Skip to content

feat: add pre-deploy hook for GitOps project syncs#3022

Merged
kmendell merged 14 commits into
getarcaneapp:mainfrom
peitschie:feat/lifecycle-hooks
Jun 28, 2026
Merged

feat: add pre-deploy hook for GitOps project syncs#3022
kmendell merged 14 commits into
getarcaneapp:mainfrom
peitschie:feat/lifecycle-hooks

Conversation

@peitschie

@peitschie peitschie commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Re-raise of #2782

Fixes: #2249

Checklist

  • This PR is not opened from my fork's main branch

What 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.runtime for an env_file: .env.runtime service) without Arcane-specific machinery in the loop.

THIS FEATURE IS DISABLED BY DEFAULT. There's currently no UI for category=security settings (the Security category is hidden in settings/+page.svelte), so admins enable via LIFECYCLE_ENABLED=true (env override) or PUT /api/environments/{id}/settings.

Addresses: #1707

Changes Made

  • Backend (feat(lifecycle): pre-deploy hook backend ...): migration 053 + LifecycleService + MountForSubpath helper. Runner is hardened (no-new-privileges, CapDrop ALL, default network=none, workspace-only mount, configurable timeout) and gated behind a lifecycleEnabled setting (default off, envOverride). Walker preserves git's +x bit so committed scripts arrive runnable. Validator returns *models.ValidationError with field attribution; GitOps error wrappers now surface inner errors via %v so dialog toasts show the actual reason. Mount strategy detects whether /app/data is a host bind or a named volume and picks TypeBind-with-joined-source or TypeVolume+VolumeOptions.Subpath accordingly.
  • Frontend (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 via fileFilter/fileBadge/footerHint snippets + 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.
  • Chore: .gitattributes for husky + Justfile + *.sh LF enforcement (the only things that actually break under CRLF).

Testing Done

  • Development environment started: ./scripts/development/dev.sh start
  • Frontend verified at http://localhost:3000/
  • Backend verified at http://localhost:3552/
  • Manual testing completed: full feature matrix via a 20-scenario Playwright e2e suite covering the happy paths (create, edit, perform deploy with hook), validator rejections, file-browser modes, network=none vs bridge, timeout enforcement, sync-table indicator, project-view rendering, plus security probes (no docker.sock in runner, workspace isolation between projects, walker skips committed symlinks, hook failure doesn't leave projects stuck "deploying", timeout doesn't leak runner containers, kill switch blocks via API, env vars round-trip). End-to-end SOPS-style env-transform pattern verified: hook writes .env.runtime, compose env_file: reads it.
  • No linting errors: gofmt -l clean, go vet clean, pnpm check 0 errors / 0 warnings
  • Backend tests pass: new + existing lifecycle/gitops/mount/walker tests. One pre-existing test (TestProjectService_ListProjects_WithDerivedStatusFilter_AllowsAllPageSizeSentinel) fails on this PR and on a clean upstream/main worktree — 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 via git daemon for 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

03-add-sync-basic-filled 04-add-sync-predeploy-empty 05-add-sync-predeploy-filled 08-file-browser-script-rooted-at-compose-dir 09-file-browser-script-inside-scripts 10-file-browser-script-selected-path-populated 11-validation-toast-title-description 12-sync-table-lifecycle-indicator 14-project-script-tab-in-row 15-project-script-content-readonly 02-add-sync-default

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 new gitops:lifecycle permission (Admin-role only).

  • Backend: Migration 061 adds nine new columns to gitops_syncs; LifecycleService runs the hook with no-new-privileges, CapDrop ALL, workspace-only bind mount, and a configurable network mode (default none); GitOpsSyncService adds lifecycle validation and field-apply helpers; redeployIfRunningAfterSync now returns an error that is surfaced on the sync row's LastSyncError; git walker and fs_writer propagate the source's +x bit so committed scripts arrive executable.
  • Frontend: New "Pre-deploy script" collapsible in the sync dialog (permission-gated), LifecycleIndicator in the sync table, lifecycle summary on the project view; file-browser dialog generalised from compose-only to slot-based fileFilter/fileBadge/footerHint.
  • Chore: .gitattributes for 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

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.
@peitschie peitschie requested a review from a team June 24, 2026 10:35
@peitschie

Copy link
Copy Markdown
Contributor Author

Sorry this took so long @kmendell ... I lost your earlier message somehow 🙂

Comment thread backend/internal/common/errors.go
Comment thread backend/internal/services/gitops_sync_service.go
@github-actions

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve the conflicts so the PR can stay up-to-date and reviewed.

@github-actions

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve the conflicts so the PR can stay up-to-date and reviewed.

@kmendell kmendell merged commit fb0cb2e into getarcaneapp:main Jun 28, 2026
16 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

⚡️ Feature: Pre/Post Deployment Script Support (with Git-backed Scripts) for Containers, Projects, and Swarm

2 participants