feat: add pre-deploy hook for GitOps project syncs#2782
Conversation
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.
c41f263 to
4f16f16
Compare
|
Done! I've rebased this on to v2 and changed the target branch. Confirmed things still appear to work. |
kmendell
left a comment
There was a problem hiding this comment.
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. |
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.
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can stay up-to-date and reviewed. |
315d3e9 to
57e8929
Compare
|
@peitschie Github is stupid, so youll have to reopen this aggainst main . |
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.
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.

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 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 composethen consumes. The feature is disabled by default behind alifecycleEnabledsetting and a dedicatedgitops:lifecyclepermission.LifecycleServiceruns the hook in a container withCapDrop ALL,no-new-privileges, andnetwork=noneby default; output is capped at 16 KB per stream; the container is removed viadeferregardless of outcome. Migration 057 adds nine columns togitops_syncs. TheDeployProjectflow is reordered so status is set todeployingbefore the hook runs (and restored on failure), ensuring hooks can produce files compose then reads.gitops:lifecyclepermission 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 withfileFilter/fileBadge/footerHintsnippets.if mode != \"\"guard inapplyLifecycleFieldsToSyncInternal, and extra mount targets not validated against/workspaceat 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.
Prompt To Fix All With AI
Reviews (2): Last reviewed commit: "review(gitops): move redeploy-after-sync..." | Re-trigger Greptile