Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions backend/api/handlers/gitops_syncs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"github.com/danielgtaylor/huma/v2"
humamw "github.com/getarcaneapp/arcane/backend/v2/api/middleware"
"github.com/getarcaneapp/arcane/backend/v2/internal/common"
"github.com/getarcaneapp/arcane/backend/v2/internal/models"
"github.com/getarcaneapp/arcane/backend/v2/internal/services"
Expand Down Expand Up @@ -135,6 +136,25 @@ func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService)
registerGitOpsSecuredInternal(api, "browseGitOpsSyncFiles", "GET", "/environments/{id}/gitops-syncs/{syncId}/files", "Browse GitOps sync files", "Browse files in the synced repository", authz.PermGitOpsRead, h.BrowseFiles)
}

// requireLifecyclePermissionInternal rejects callers lacking gitops:lifecycle
// for the target environment when a create/update request configures the
// pre-deploy lifecycle hook. Configuring the hook lets the caller run an
// arbitrary container — with host bind mounts, env, and network access — on
// every sync, so it is gated behind its own permission (seeded only into the
// Admin built-in role) rather than the broader gitops:create / gitops:update
// permissions that non-admin roles such as Editor hold. Whether a request
// touches the hook is decided by the request type itself
// (gitops.*SyncRequest.HasPreDeployConfig) so the field set has a single owner.
func requireLifecyclePermissionInternal(ctx context.Context, environmentID string, lifecycleRequested bool) error {
if !lifecycleRequested {
return nil
}
if ps, _ := humamw.PermissionsFromContext(ctx); ps.Allows(authz.PermGitOpsLifecycle, environmentID) {
return nil
}
return huma.Error403Forbidden("configuring a pre-deploy lifecycle hook requires the " + authz.PermGitOpsLifecycle + " permission")
}

// ============================================================================
// Handler Methods
// ============================================================================
Expand Down Expand Up @@ -168,6 +188,10 @@ func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsS
return nil, huma.Error500InternalServerError("service not available")
}

if err := requireLifecyclePermissionInternal(ctx, input.EnvironmentID, input.Body.HasPreDeployConfig()); err != nil {
return nil, err
}

actor := currentActorInternal(ctx)

sync, err := h.syncService.CreateSync(ctx, input.EnvironmentID, input.Body, actor)
Expand Down Expand Up @@ -239,6 +263,10 @@ func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsS
return nil, huma.Error500InternalServerError("service not available")
}

if err := requireLifecyclePermissionInternal(ctx, input.EnvironmentID, input.Body.HasPreDeployConfig()); err != nil {
return nil, err
}

actor := currentActorInternal(ctx)

sync, err := h.syncService.UpdateSync(ctx, input.EnvironmentID, input.SyncID, input.Body, actor)
Expand Down
29 changes: 27 additions & 2 deletions backend/internal/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -1577,7 +1577,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 {
Expand All @@ -1593,7 +1593,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 {
Expand Down Expand Up @@ -1651,6 +1651,31 @@ func (e *GitOpsSyncMappingError) Error() string {
return "Failed to map GitOps sync"
}

// RedeployAfterSyncFailedError is returned by the internal GitOps 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 {
Err error
}

func (e *RedeployAfterSyncFailedError) Error() string {
if e == nil || e.Err == nil {
return "redeploy failed"
}

return "redeploy failed: " + e.Err.Error()
}

func (e *RedeployAfterSyncFailedError) Unwrap() error {
if e == nil {
return nil
}

return e.Err
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

type VulnerabilityScanError struct {
Err error
}
Expand Down
44 changes: 44 additions & 0 deletions backend/internal/common/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package common

import (
"errors"
"io"
"testing"
)

func TestRedeployAfterSyncFailedErrorZeroValue(t *testing.T) {
err := &RedeployAfterSyncFailedError{}

if got := err.Error(); got != "redeploy failed" {
t.Fatalf("Error() = %q, want %q", got, "redeploy failed")
}

if errors.Is(err, io.EOF) {
t.Fatal("errors.Is matched an unrelated error")
}
}

func TestRedeployAfterSyncFailedErrorNilReceiver(t *testing.T) {
var err *RedeployAfterSyncFailedError

if got := err.Error(); got != "redeploy failed" {
t.Fatalf("Error() = %q, want %q", got, "redeploy failed")
}

if got := err.Unwrap(); got != nil {
t.Fatalf("Unwrap() = %v, want nil", got)
}
}

func TestRedeployAfterSyncFailedErrorWrapsErr(t *testing.T) {
cause := io.EOF
err := &RedeployAfterSyncFailedError{Err: cause}

if got := err.Error(); got != "redeploy failed: EOF" {
t.Fatalf("Error() = %q, want %q", got, "redeploy failed: EOF")
}

if !errors.Is(err, cause) {
t.Fatal("errors.Is did not match wrapped error")
}
}
3 changes: 3 additions & 0 deletions backend/internal/configschema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,9 @@ var expectedSettingOverrideKeys = []string{
"httpClientTimeout",
"iconCatalog",
"keyboardShortcutsEnabled",
"lifecycleDefaultRunnerImage",
"lifecycleEnabled",
"lifecycleMaxTimeoutSec",
"maxImageUploadSize",
"mobileNavigationMode",
"mobileNavigationShowLabels",
Expand Down
5 changes: 3 additions & 2 deletions backend/internal/di/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, kv *services.KVService, 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, kv *services.KVService, environment *services.EnvironmentService, cfg *config.Config) *services.ProjectService {
return services.NewProjectService(db, settings, event, image, docker, build, lifecycle, cfg).
WithKVService(kv).
WithRegistryCredentialsProvider(environment.GetEnabledRegistryCredentials)
}
Expand Down
1 change: 1 addition & 0 deletions backend/internal/di/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ var ServiceSet = wire.NewSet(
services.NewImageService,
services.NewBuildService,
services.NewBuildWorkspaceService,
services.NewLifecycleService,
provideProjectServiceInternal,
services.NewContainerService,
services.NewDashboardService,
Expand Down
6 changes: 4 additions & 2 deletions backend/internal/di/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions backend/internal/models/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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"
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/models/gitops_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/models/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ 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"`
LifecycleDefaultRunnerImage SettingVariable `key:"lifecycleDefaultRunnerImage,envOverride" meta:"label=Lifecycle Hook Runner Image;type=text;keywords=lifecycle,hook,runner,image,container,pre-deploy,script,gitops;category=security;description=Default container image used to run GitOps pre-deploy lifecycle scripts when a sync does not override it"`
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"`
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/services/dashboard_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,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{})
Expand Down
Loading
Loading