Skip to content

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

Closed
peitschie wants to merge 40 commits into
getarcaneapp:breaking/v2.0.0from
peitschie:feat/lifecycle-hooks
Closed

feat: add pre-deploy hook for GitOps project syncs#2782
peitschie wants to merge 40 commits into
getarcaneapp:breaking/v2.0.0from
peitschie:feat/lifecycle-hooks

Conversation

@peitschie

@peitschie peitschie commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

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 introduces an opt-in pre-deploy lifecycle hook for GitOps stacks: a script committed in the synced repo runs in a hardened throwaway container immediately before each deploy, enabling patterns like SOPS secret decryption into files that docker compose then consumes. The feature is disabled by default behind a lifecycleEnabled setting and a dedicated gitops:lifecycle permission.

  • Backend: LifecycleService runs the hook in a container with CapDrop ALL, no-new-privileges, and network=none by default; output is capped at 16 KB per stream; the container is removed via defer regardless of outcome. Migration 057 adds nine columns to gitops_syncs. The DeployProject flow is reordered so status is set to deploying before the hook runs (and restored on failure), ensuring hooks can produce files compose then reads.
  • Frontend: Lifecycle config is gated behind the gitops:lifecycle permission check in both the handler and the UI. A new collapsible in the sync dialog exposes script path, runner image, timeout, network mode, env, and extra mounts. The file-browser dialog is generalised with fileFilter/fileBadge/footerHint snippets.
  • Two minor quality notes: a dead if mode != \"\" guard in applyLifecycleFieldsToSyncInternal, and extra mount targets not validated against /workspace at configuration time.

Confidence Score: 5/5

The feature is disabled by default and gated by a dedicated admin-only permission; the runner is hardened with capability drops and network isolation; all new code paths have tests. Safe to merge.

The lifecycle runner is carefully hardened, the permission model is enforced at both the handler and service layers, and the deploy flow handles hook failures cleanly. The two findings are minor quality issues with no impact on correctness or security.

No files require special attention; the two minor findings are in gitops_sync_service.go and lifecycle_service.go.

Fix All in Codex Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
backend/internal/services/lifecycle_service.go:407-426
**Extra mount target not validated against workspace path**

`parseLifecycleExtraMountsTextInternal` validates that source and target are absolute paths, but it does not check whether a target conflicts with `lifecycleWorkspaceMount` (`/workspace`) or any subpath underneath it. If an admin accidentally sets a target of `/workspace`, `buildLifecycleMountsInternal` will append it after the workspace mount. Docker will then reject `ContainerCreate` at deploy time with an opaque "duplicate mount point" error rather than a clear validation message at configuration save time. Adding a check like `strings.HasPrefix(target, lifecycleWorkspaceMount)` in the mount parser would surface this as a readable validation error earlier.

### Issue 2 of 2
backend/internal/services/gitops_sync_service.go:211-213
The guard `if mode != ""` is always true because `normalizeLifecycleNetworkModeInternal` always returns either `"none"` (for nil or empty input) or the trimmed non-empty user value — it never returns an empty string. The dead branch could mislead future readers into thinking an empty mode could be written to the struct, when it cannot be.

```suggestion
	sync.PreDeployNetworkMode = normalizeLifecycleNetworkModeInternal(in.networkMode)
```

Reviews (2): Last reviewed commit: "review(gitops): move redeploy-after-sync..." | Re-trigger Greptile

kmendell and others added 30 commits May 31, 2026 18:41
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
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.
@peitschie peitschie force-pushed the feat/lifecycle-hooks branch from c41f263 to 4f16f16 Compare June 1, 2026 10:17
@peitschie peitschie changed the base branch from main to breaking/v2.0.0 June 1, 2026 10:19
@peitschie

Copy link
Copy Markdown
Contributor Author

Done! I've rebased this on to v2 and changed the target branch. Confirmed things still appear to work.

@kmendell kmendell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since v2 has RBAC support i think the frontned should limti (as well as backend potentially) the lifecycle submissiosn to a specific role and/or global admin only sicne it could cause a big security risk

Comment thread backend/internal/services/gitops_sync_service.go Outdated
@peitschie

Copy link
Copy Markdown
Contributor Author

Since v2 has RBAC support i think the frontned should limti (as well as backend potentially) the lifecycle submissiosn to a specific role and/or global admin only sicne it could cause a big security risk

I am a bit unsure of which role exactly. I've chosen to add a new role (gitops:lifecycle) granted to Admin (global) only.

But... this has been designed with security in mind, so there is an argument that it should be ok for Editor and No-Shell Editor to be able to do this as well (i.e., anyone that can gitops:create/gitops:update).

The script itself is contained within an ephemeral docker image with only access to the project's own folder. The hook itself is contained in the same way as the stack, meaning that it cannot access anything on the host or outside the project by design. You could build a compose file today that does almost the same thing... the only reason a hook is needed at all is so we can modify files before compose runs (e.g., to unencrypt environment variables).

Let me know if you think the role is unnecessary and we can just drop back to create/update.

peitschie added 2 commits June 2, 2026 13:01
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

Copy link
Copy Markdown
Contributor Author

Here's the new non-admin version of the Git Sync dialog (note the readonly Pre-deploy script, which also forces Sync Files to be readonly):

gitops-readonly-dialog

@kmendell kmendell changed the title Add a pre-deploy hook for GitOps stacks only to enable SOPS-style workflows (among other things) feat: a pre-deploy hook for GitOps project syncs Jun 2, 2026
@kmendell kmendell changed the title feat: a pre-deploy hook for GitOps project syncs feat: add pre-deploy hook for GitOps project syncs Jun 2, 2026
@github-actions

github-actions Bot commented Jun 2, 2026

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 force-pushed the breaking/v2.0.0 branch 6 times, most recently from 315d3e9 to 57e8929 Compare June 4, 2026 17:58
@kmendell kmendell deleted the branch getarcaneapp:breaking/v2.0.0 June 7, 2026 02:49
@kmendell kmendell closed this Jun 7, 2026
@kmendell

kmendell commented Jun 7, 2026

Copy link
Copy Markdown
Member

@peitschie Github is stupid, so youll have to reopen this aggainst main .

peitschie added a commit to peitschie/arcane that referenced this pull request Jun 9, 2026
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.
peitschie added a commit to peitschie/arcane that referenced this pull request Jun 9, 2026
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.
peitschie added a commit to peitschie/arcane that referenced this pull request Jun 9, 2026
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.
peitschie added a commit to peitschie/arcane that referenced this pull request Jun 24, 2026
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.
peitschie added a commit to peitschie/arcane that referenced this pull request Jun 24, 2026
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.
peitschie added a commit to peitschie/arcane that referenced this pull request Jun 24, 2026
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.
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.

3 participants