diff --git a/.depot/workflows/build-v2.yml b/.depot/workflows/build-v2.yml new file mode 100644 index 0000000000..716b894a2c --- /dev/null +++ b/.depot/workflows/build-v2.yml @@ -0,0 +1,142 @@ +name: Build V2 Beta Images + +on: + push: + branches: + - breaking/v2.0.0 + workflow_dispatch: + +concurrency: + group: build-v2-beta-images + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + build-v2-beta-images: + runs-on: depot-ubuntu-24.04-32 + + env: + MANAGER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/manager + AGENT_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/agent + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install envsubst + run: sudo apt-get update && sudo apt-get install -y gettext-base + + - name: Install cosign + uses: sigstore/cosign-installer@v4.1.2 + + - name: Set up Depot CLI + uses: depot/setup-action@v1 + + - name: Setup depot buildx driver + id: setup-buildx-driver + run: | + depot configure-docker + + # - name: Snapshot Setup Environment + # uses: depot/snapshot-action@v1 + # with: + # image: g7r5wqb57k.registry.depot.dev/arcane-env:latest + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ vars.GHCR_USERNAME }} + password: ${{ secrets.ARCANE_BOT_TOKEN }} + + - name: Manager image metadata + id: manager-meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.MANAGER_IMAGE_NAME }} + tags: | + type=raw,value=v2-beta + labels: | + org.opencontainers.image.authors=OFKM Technologies + org.opencontainers.image.url=https://github.com/getarcaneapp/arcane + org.opencontainers.image.documentation=https://github.com/getarcaneapp/arcane/blob/main/README.md + org.opencontainers.image.source=https://github.com/getarcaneapp/arcane + org.opencontainers.image.version=v2-beta + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=BSD-3-Clause + org.opencontainers.image.ref.name=arcane + org.opencontainers.image.title=Arcane + org.opencontainers.image.description=Modern Docker Management, Made for Everyone + com.getarcaneapp.arcane=true + + - name: Build and push manager image + id: manager-build + uses: depot/build-push-action@v1 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.manager-meta.outputs.tags }} + labels: ${{ steps.manager-meta.outputs.labels }} + build-args: | + VERSION=v2.0.0-beta + REVISION=${{ github.sha }} + sbom: false + provenance: true + + - name: Sign manager image + run: cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${MANAGER_IMAGE_NAME}@${{ steps.manager-build.outputs.digest }}" + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + + - name: Agent image metadata + id: agent-meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.AGENT_IMAGE_NAME }} + tags: | + type=raw,value=v2-beta + labels: | + org.opencontainers.image.authors=OFKM Technologies + org.opencontainers.image.url=https://github.com/getarcaneapp/arcane + org.opencontainers.image.documentation=https://github.com/getarcaneapp/arcane/blob/main/README.md + org.opencontainers.image.source=https://github.com/getarcaneapp/arcane + org.opencontainers.image.version=v2-beta + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.licenses=BSD-3-Clause + org.opencontainers.image.ref.name=arcane-agent + org.opencontainers.image.title=Arcane Agent + org.opencontainers.image.description=Arcane Agent + com.getarcaneapp.arcane=true + com.getarcaneapp.arcane.agent=true + + - name: Build and push agent image + id: agent-build + uses: depot/build-push-action@v1 + with: + context: . + file: docker/Dockerfile-agent + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.agent-meta.outputs.tags }} + labels: ${{ steps.agent-meta.outputs.labels }} + build-args: | + VERSION=v2.0.0-beta + REVISION=${{ github.sha }} + sbom: false + provenance: true + + - name: Sign agent image + run: cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${AGENT_IMAGE_NAME}@${{ steps.agent-build.outputs.digest }}" + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..5bcb23fcd6 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/.golangci.yml b/.github/.golangci.yml index b3703c6631..18ba7272fc 100644 --- a/.github/.golangci.yml +++ b/.github/.golangci.yml @@ -5,45 +5,92 @@ run: linters: default: none enable: + - arangolint - asasalint - asciicheck - bidichk - bodyclose + - canonicalheader + - clickhouselint - contextcheck - copyloopvar + - decorder + - dupl + - dupword - durationcheck + - embeddedstructfieldcheck - errcheck - errchkjson + - errname - errorlint - exhaustive + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - ginkgolinter - gocheckcompilerdirectives - gochecksumtype - gocognit - gocritic + - gocyclo + - godoclint + - goheader + - gomoddirectives + - gomodguard_v2 + - goprintffuncname - gosec - gosmopolitan - govet + - grouper + - iface + - importas + - inamedparam - ineffassign + - interfacebloat + - intrange + - iotamixing - loggercheck + - maintidx - makezero - - misspell - mirror + - misspell + - modernize - musttag - nakedret - nilerr - nilnesserr - noctx + - nolintlint + - nosprintfhostport + - perfsprint + - prealloc + - predeclared + - promlinter - protogetter - reassign - recvcheck + - revive - rowserrcheck + - sloglint - spancheck - sqlclosecheck - staticcheck + - testableexamples - testifylint + - unconvert + - unparam + - unqueryvet - unused - usestdlibvars + - usetesting + - wastedassign + - whitespace - zerologlint + settings: + gomoddirectives: + # replace directives (=> ../cli, => ../types). Allow them. + replace-local: true exclusions: generated: lax presets: @@ -51,6 +98,25 @@ linters: - common-false-positives - legacy - std-error-handling + rules: + # Initialism naming (ApiKey -> APIKey, AppUrl -> AppURL, etc.) is intentional, + # widespread, and API-surface; track separately rather than churn it here. + - linters: + - revive + text: "var-naming:" + # Unused params are required by interface/callback/gorm-hook signatures. + - linters: + - revive + text: "unused-parameter:" + # Package-prefixed exported names (swarm.SwarmInfo, port.PortMapping, etc.) are + # an intentional, monorepo-wide convention; renaming is a large public-API change. + - linters: + - revive + text: "exported:" + # The cli command package is a CLI entrypoint; printing to stdout is correct. + - linters: + - forbidigo + path: cli/ paths: - '.*\.pb\.go$' - third_party$ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a03fa39540..e819205945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,7 +223,7 @@ jobs: e2e-tests: name: E2E Tests (${{ matrix.database.name }}) if: ${{ github.head_ref != 'l10n_crowdin' }} - runs-on: depot-ubuntu-latest + runs-on: depot-ubuntu-24.04-4 permissions: contents: read actions: write @@ -301,7 +301,7 @@ jobs: cli-e2e-tests: name: CLI E2E Tests (proxy) if: ${{ github.head_ref != 'l10n_crowdin' }} - runs-on: depot-ubuntu-latest + runs-on: depot-ubuntu-24.04-4 permissions: contents: read actions: write diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index 028ef0dadf..d73ae5002b 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -10,12 +10,12 @@ name: Hadolint on: push: - branches: [ "main" ] + branches: ["main"] pull_request: # The branches below must be a subset of the branches above - branches: [ "main" ] + branches: ["main"] schedule: - - cron: '24 19 * * 2' + - cron: "24 19 * * 2" permissions: contents: read @@ -28,6 +28,16 @@ jobs: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + strategy: + fail-fast: false + matrix: + include: + - name: manager + dockerfile: ./docker/Dockerfile + output: hadolint-results-manager.sarif + - name: agent + dockerfile: ./docker/Dockerfile-agent + output: hadolint-results-agent.sarif steps: - name: Checkout code uses: actions/checkout@v6 @@ -35,13 +45,14 @@ jobs: - name: Run hadolint uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 with: - dockerfile: ./docker/Dockerfile + dockerfile: ${{ matrix.dockerfile }} format: sarif - output-file: hadolint-results.sarif + output-file: ${{ matrix.output }} no-fail: true - name: Upload analysis results to GitHub uses: github/codeql-action/upload-sarif@v4.36.0 with: - sarif_file: hadolint-results.sarif + sarif_file: ${{ matrix.output }} + category: hadolint-${{ matrix.name }} wait-for-processing: true diff --git a/.vscode/settings.json b/.vscode/settings.json index 3807e18171..6c72a52a54 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,11 +6,11 @@ "scminput": false, "typescript": true }, - "typescript.tsdk": "frontend/node_modules/typescript/lib", "go.buildTags": "playwright,buildables", "prettier.documentSelectors": ["**/*.svelte"], "tasks.statusbar.default.hide": true, "tasks.statusbar.limit": 8, "github.copilot.chat.summarizeAgentConversationHistory.enabled": false, - "snyk.advanced.autoSelectOrganization": false + "snyk.advanced.autoSelectOrganization": false, + "js/ts.tsdk.path": "frontend/node_modules/typescript/lib" } diff --git a/AGENTS.md b/AGENTS.md index f702551369..deb1403f63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,16 @@ internal/ ├── models/ # GORM database models (include BaseModel for UUID, timestamps) └── services/ # Business logic — *_service.go files contain domain logic pkg/ -├── libarcane/ # Core reusable backend/domain libraries -├── projects/ # Compose project parsing and filesystem helpers +├── authz/ # Permission taxonomy and authorization primitives +├── dockerutil/ # Low-level Docker helpers (container names, compose labels, log streaming, mounts/volumes/networks) +├── fswatch/ # Filesystem watcher that detects project file changes +├── gitutil/ # Git client for GitOps (clone, branches, file tree, read) +├── libarcane/ # Core reusable backend/domain libraries (compose deploy, edge, image update, build, swarm) +├── pagination/ # Search/filter/sort/pagination helpers (DB and in-memory) +├── projects/ # Compose project parsing, env, includes, discovery, filesystem helpers +├── remenv/ # Remote-environment request/response transport client (agent/edge) ├── scheduler/ # Cron-backed background jobs -├── pagination/ # Search/filter/sort/pagination helpers -└── utils/ # Shared helper utilities +└── utils/ # Shared helper utilities (strings, cache, ptr, httpx, mapper) resources/ # migrations, images, fonts, email templates ``` @@ -57,6 +62,14 @@ resources/ # migrations, images, fonts, email templates - Use `slog` for structured logging with context - Error wrapping: `fmt.Errorf("context: %w", err)` +**Reuse `pkg/` helpers — do not duplicate or wrap:** Before writing inline logic, check `pkg/` for an existing helper: + +- Docker primitives → `pkg/dockerutil`: container display names (`ContainerNameFromNames`, `ContainerSummaryName`), Compose labels (`ComposeProjectLabel`/`ComposeServiceLabel`, keyed by `ComposeProjectLabelKey`/`ComposeServiceLabelKey`), log streaming (`StreamContainerLogs`/`StreamMultiplexedLogs`/`ReadAllLogs`), and mount/volume/network/cgroup utilities. +- Compose parsing → `pkg/projects`: image-ref extraction (`ImageRefsFromComposeServices`/`ImageRefsFromComposeConfigs`/`ImageRefsFromRuntimeServices`), plus compose load, env state, includes, and project discovery. +- Listing endpoints → `pkg/pagination` (`SearchOrderAndPaginate`, `PaginateAndSortDB`); shared string/ptr/cache helpers → `pkg/utils`. + +Call middleware/library functions directly at the call site. Do not add thin pass-through helpers or stubs that only forward to a single underlying call. + ### API Layer (`backend/api/`) ``` @@ -379,6 +392,18 @@ newScheduler.RegisterJob(myJob) **Note:** Cron uses 6 fields (with seconds): `"0 0 * * * *"` = every hour at :00:00 +## Image & Container Update Logic + +Update detection and application are split across layers — change behavior at the matching layer: + +- **`backend/pkg/libarcane/imageupdate/`** — comparison primitives: `digest.go` (DigestChecker: local vs remote digest), `registry_http.go` (registry digest / rate-limit queries, reference parsing), `labels.go` (Arcane/Compose label checks, self-redeploy guards), `sorter.go` (dependency-ordered container restart). +- **`backend/internal/services/image_update_service.go`** — checks for updates and persists update records (`CheckImageUpdate`, `CheckMultipleImages`, `CheckAllImages`, `GetUpdateSummary`, `MarkImageRefUpToDateAfterPull`). +- **`backend/internal/services/updater_service.go`** — applies updates to running containers (`ApplyPending`, `UpdateSingleContainer`), with status/history. +- **`backend/api/handlers/image_updates.go`** — check/query API (`check-image-update`, `check-all-images`, `get-update-summary`); **`updater.go`** — apply API (`run-updater`, `update-container`, status/history). +- **`backend/pkg/scheduler/`** — background drivers: `image_polling_job.go` (periodic checks), `auto_update_job.go` (auto-apply), `auto_heal_job.go` (restart unhealthy containers). + +Project image references are cached on the project row (`image_refs_json`, extracted via the `pkg/projects` `ImageRefsFrom*` helpers); per-project update summaries are assembled in `project_service.go`. + ## Database Patterns (GORM) ### BaseModel @@ -431,9 +456,9 @@ Edge agents connect outbound to a central manager via WebSocket or gRPC tunnel, **Configuration** (agent side): ```bash -ARCANE_EDGE_AGENT=true -ARCANE_MANAGER_API_URL=https://manager.example.com -ARCANE_AGENT_TOKEN= +EDGE_AGENT=true +MANAGER_API_URL=https://manager.example.com +AGENT_TOKEN= ``` **Message types** (see [tunnel.go](backend/pkg/libarcane/edge/tunnel.go)): @@ -449,11 +474,11 @@ Direct agents are passive: the agent runs an HTTP server on TCP 3553 and the man **Configuration** (agent side): ```bash -ARCANE_AGENT_MODE=true -ARCANE_AGENT_TOKEN= +AGENT_MODE=true +AGENT_TOKEN= ``` -`ARCANE_MANAGER_API_URL` is **not** used in Direct mode — the agent never dials out. Pairing completes when the manager's periodic health-check (every 2 min by default) reaches the agent. +`MANAGER_API_URL` is **not** used in Direct mode — the agent never dials out. Pairing completes when the manager's periodic health-check (every 2 min by default) reaches the agent. **When implementing agent features:** diff --git a/Justfile b/Justfile index 23ea143cde..993447d78f 100644 --- a/Justfile +++ b/Justfile @@ -156,10 +156,58 @@ _build-image-manager tag="ghcr.io/getarcaneapp/arcane:development" flag='': _build-image-agent tag="ghcr.io/getarcaneapp/arcane-headless:development" flag='': docker buildx build {{ if flag == "--push" { "--push" } else { "" } }} --platform linux/arm64,linux/amd64,linux/arm/v7 -f 'docker/Dockerfile-agent' --build-arg ENABLED_FEATURES="{{ env('ENABLED_FEATURES', env('BUILD_FEATURES', '')) }}" -t "{{ tag }}" . -# Build targets. Valid: "single frontend", "single backend", "single all", "image manager [tag] [--push]", "image agent [tag] [--push]" +# Build + push both manager and agent multi-arch images for a beta release. +# +# Tag pattern: ghcr.io/getarcaneapp/{manager,agent}:-beta +# Version flag: .0.0-beta (compiled into the binary via -ldflags), +# unless an explicit version is supplied as the second arg. +# +# Examples: +# just build v2 tags :v2-beta, VERSION=v2.0.0-beta +# just build v2 v2.0.0-beta.2 tags :v2-beta, VERSION=v2.0.0-beta.2 [group('build')] -build buildtype type tag="" flag="": - @if [ "{{ buildtype }}" = "single" ]; then just _build-{{ type }}; elif [ "{{ buildtype }}" = "image" ]; then just _build-image-{{ type }} "{{ if tag != "" { tag } else if type == "manager" { "arcane:latest" } else { "arcane-agent:latest" } }}" "{{ flag }}"; fi +_build-release release version="": + #!/usr/bin/env bash + set -euo pipefail + + if [ -z "{{ release }}" ]; then + echo "Release shortcut is required, e.g. 'just build v2'" >&2 + exit 1 + fi + + image_tag="{{ release }}-beta" + version="{{ if version != "" { version } else { release + ".0.0-beta" } }}" + + echo "==> Building manager image ghcr.io/getarcaneapp/manager:${image_tag} (VERSION=${version})" + docker buildx build \ + --tag "ghcr.io/getarcaneapp/manager:${image_tag}" \ + --push \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="${version}" \ + -f docker/Dockerfile . + + echo "==> Building agent image ghcr.io/getarcaneapp/agent:${image_tag} (VERSION=${version})" + docker buildx build \ + --tag "ghcr.io/getarcaneapp/agent:${image_tag}" \ + --push \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="${version}" \ + -f docker/Dockerfile-agent . + + echo "" + echo "✓ Pushed manager + agent images tagged :${image_tag} (VERSION=${version})" + +# Build targets: +# just build single {frontend|backend|all} +# just build image {manager|agent} [tag] [--push] +# just build [version] e.g. just build v2 -> push manager+agent :v2-beta with VERSION=v2.0.0-beta +[group('build')] +build buildtype type="" tag="" flag="": + @if [ "{{ buildtype }}" = "single" ]; then just _build-{{ type }}; \ + elif [ "{{ buildtype }}" = "image" ]; then just _build-image-{{ type }} "{{ if tag != "" { tag } else if type == "manager" { "arcane:latest" } else { "arcane-agent:latest" } }}" "{{ flag }}"; \ + elif echo "{{ buildtype }}" | grep -qE '^v[0-9]'; then just _build-release "{{ buildtype }}" "{{ type }}"; \ + else echo "Unknown build target: {{ buildtype }}. Try: just build single|image|" >&2; exit 1; \ + fi # ----------------------------------------------------------------------------- # Test @@ -395,35 +443,35 @@ deps action="update" target="all": # ----------------------------------------------------------------------------- # Run go mod tidy in backend module -[group('go-modules')] +[group('gomod')] _gomod-tidy-backend: cd backend && go mod tidy # Run go mod tidy in CLI module -[group('go-modules')] +[group('gomod')] _gomod-tidy-cli: cd cli && go mod tidy # Run go mod tidy in types module -[group('go-modules')] +[group('gomod')] _gomod-tidy-types: cd types && go mod tidy # Run go mod tidy in all Go modules -[group('go-modules')] +[group('gomod')] _gomod-tidy-go: _gomod-tidy-backend _gomod-tidy-cli _gomod-tidy-types -[group('go-modules')] +[group('gomod')] _gomod-tidy-all: @just _gomod-tidy-go go work sync -[group('go-modules')] +[group('gomod')] _gomod-sync-all: go work sync # Go module targets. Valid: "tidy [backend|cli|types|go|all]", "sync all". -[group('go-modules')] +[group('gomod')] gomod action="tidy" target="all": @just "_gomod-{{ action }}-{{ target }}" @@ -433,8 +481,18 @@ gomod action="tidy" target="all": # Generate edge tunnel protobuf/gRPC code. [group('codegen')] -proto-backend: - cd {{ edge_proto_dir }} && go run github.com/bufbuild/buf/cmd/buf@v1.65.0 generate +_generate-proto: + cd {{ edge_proto_dir }} && go run github.com/bufbuild/buf/cmd/buf@latest generate + +# Generate Wire dependency injection code. +[group('codegen')] +_generate-wire: + cd backend && go tool wire ./... + +# Generate targets. Valid: "proto", "wire". +[group('codegen')] +generate target: + @just "_generate-{{ target }}" # Generate the docs config schema JSON. [group('docs')] diff --git a/backend/api/api.go b/backend/api/api.go index 0d3d2143bb..c195f2403a 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "reflect" "strings" @@ -10,7 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/api/handlers" "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/config" - "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/labstack/echo/v4" ) @@ -136,49 +137,8 @@ func capitalizeFirst(s string) string { return strings.ToUpper(s[:1]) + s[1:] } -// Services holds all service dependencies needed by Huma handlers. -type Services struct { - User *services.UserService - Auth *services.AuthService - Oidc *services.OidcService - ApiKey *services.ApiKeyService - AppImages *services.ApplicationImagesService - Font *services.FontService - Project *services.ProjectService - Event *services.EventService - Version *services.VersionService - Environment *services.EnvironmentService - Settings *services.SettingsService - JobSchedule *services.JobService - SettingsSearch *services.SettingsSearchService - ContainerRegistry *services.ContainerRegistryService - Template *services.TemplateService - Docker *services.DockerClientService - Image *services.ImageService - ImageUpdate *services.ImageUpdateService - Build *services.BuildService - BuildWorkspace *services.BuildWorkspaceService - Volume *services.VolumeService - Container *services.ContainerService - Network *services.NetworkService - Port *services.PortService - Swarm *services.SwarmService - Notification *services.NotificationService - Apprise *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr - Updater *services.UpdaterService - CustomizeSearch *services.CustomizeSearchService - System *services.SystemService - SystemUpgrade *services.SystemUpgradeService - GitRepository *services.GitRepositoryService - GitOpsSync *services.GitOpsSyncService - Webhook *services.WebhookService - Vulnerability *services.VulnerabilityService - Dashboard *services.DashboardService - Config *config.Config -} - // SetupAPI creates and configures the Huma API attached to the Echo router. -func SetupAPI(e *echo.Echo, apiGroup *echo.Group, cfg *config.Config, svc *Services) huma.API { +func SetupAPI(e *echo.Echo, apiGroup *echo.Group, appCtx handlers.ActivityAppContext, cfg *config.Config, svc *di.Services) huma.API { humaConfig := huma.DefaultConfig("Arcane API", config.Version) humaConfig.Info.Description = "Modern Docker Management, Designed for Everyone" @@ -224,10 +184,10 @@ func SetupAPI(e *echo.Echo, apiGroup *echo.Group, cfg *config.Config, svc *Servi api := humaecho.NewWithGroup(e, apiGroup, humaConfig) // Add authentication middleware - api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, svc.Environment, cfg)) + api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, svc.Role, svc.Environment, cfg)) // Register all Huma handlers - registerHandlers(api, svc) + registerHandlersInternal(api, svc, appCtx, cfg) // Register Scalar API docs endpoint with dark mode registerScalarDocs(apiGroup) @@ -303,121 +263,52 @@ func SetupAPIForSpec() huma.API { api := humaecho.NewWithGroup(e, apiGroup, humaConfig) // Register handlers with nil services (just for schema) - registerHandlers(api, nil) + registerHandlersInternal(api, nil, handlers.NewActivityAppContext(context.Background()), nil) return api } // registerHandlers registers all Huma-based API handlers. // Add new handlers here as they are migrated from Gin. -func registerHandlers(api huma.API, svc *Services) { - var userSvc *services.UserService - var authSvc *services.AuthService - var oidcSvc *services.OidcService - var apiKeySvc *services.ApiKeyService - var appImagesSvc *services.ApplicationImagesService - var fontSvc *services.FontService - var projectSvc *services.ProjectService - var eventSvc *services.EventService - var versionSvc *services.VersionService - var environmentSvc *services.EnvironmentService - var settingsSvc *services.SettingsService - var jobScheduleSvc *services.JobService - var settingsSearchSvc *services.SettingsSearchService - var containerRegistrySvc *services.ContainerRegistryService - var templateSvc *services.TemplateService - var dockerSvc *services.DockerClientService - var imageSvc *services.ImageService - var imageUpdateSvc *services.ImageUpdateService - var buildSvc *services.BuildService - var buildWorkspaceSvc *services.BuildWorkspaceService - var volumeSvc *services.VolumeService - var containerSvc *services.ContainerService - var networkSvc *services.NetworkService - var portSvc *services.PortService - var swarmSvc *services.SwarmService - var notificationSvc *services.NotificationService - var appriseSvc *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr - var updaterSvc *services.UpdaterService - var customizeSearchSvc *services.CustomizeSearchService - var systemSvc *services.SystemService - var systemUpgradeSvc *services.SystemUpgradeService - var gitRepositorySvc *services.GitRepositoryService - var gitOpsSyncSvc *services.GitOpsSyncService - var webhookSvc *services.WebhookService - var vulnerabilitySvc *services.VulnerabilityService - var dashboardSvc *services.DashboardService - var cfg *config.Config - - if svc != nil { - userSvc = svc.User - authSvc = svc.Auth - oidcSvc = svc.Oidc - apiKeySvc = svc.ApiKey - appImagesSvc = svc.AppImages - fontSvc = svc.Font - projectSvc = svc.Project - eventSvc = svc.Event - versionSvc = svc.Version - environmentSvc = svc.Environment - settingsSvc = svc.Settings - jobScheduleSvc = svc.JobSchedule - settingsSearchSvc = svc.SettingsSearch - containerRegistrySvc = svc.ContainerRegistry - templateSvc = svc.Template - dockerSvc = svc.Docker - imageSvc = svc.Image - imageUpdateSvc = svc.ImageUpdate - buildSvc = svc.Build - buildWorkspaceSvc = svc.BuildWorkspace - volumeSvc = svc.Volume - containerSvc = svc.Container - networkSvc = svc.Network - portSvc = svc.Port - swarmSvc = svc.Swarm - notificationSvc = svc.Notification - appriseSvc = svc.Apprise - updaterSvc = svc.Updater - customizeSearchSvc = svc.CustomizeSearch - systemSvc = svc.System - systemUpgradeSvc = svc.SystemUpgrade - gitRepositorySvc = svc.GitRepository - gitOpsSyncSvc = svc.GitOpsSync - webhookSvc = svc.Webhook - vulnerabilitySvc = svc.Vulnerability - dashboardSvc = svc.Dashboard - cfg = svc.Config +func registerHandlersInternal(api huma.API, svc *di.Services, handlerAppCtx handlers.ActivityAppContext, cfg *config.Config) { + // svc is nil during OpenAPI spec generation (SetupAPIForSpec); an empty + // container keeps every field a true-nil pointer so handler nil-guards hold. + if svc == nil { + svc = &di.Services{} } handlers.RegisterHealth(api) - handlers.RegisterAuth(api, userSvc, authSvc, oidcSvc) - handlers.RegisterApiKeys(api, apiKeySvc) - handlers.RegisterAppImages(api, appImagesSvc) - handlers.RegisterFonts(api, fontSvc) - handlers.RegisterProjects(api, projectSvc) - handlers.RegisterUsers(api, userSvc, authSvc) - handlers.RegisterVersion(api, versionSvc) - handlers.RegisterEvents(api, eventSvc, apiKeySvc) - handlers.RegisterOidc(api, authSvc, oidcSvc, cfg) - handlers.RegisterEnvironments(api, environmentSvc, settingsSvc, apiKeySvc, eventSvc, cfg) - handlers.RegisterContainerRegistries(api, containerRegistrySvc, environmentSvc) - handlers.RegisterTemplates(api, templateSvc, environmentSvc) - handlers.RegisterImages(api, dockerSvc, imageSvc, imageUpdateSvc, settingsSvc, buildSvc) - handlers.RegisterBuildWorkspaces(api, buildWorkspaceSvc) - handlers.RegisterImageUpdates(api, imageUpdateSvc, imageSvc) - handlers.RegisterSettings(api, settingsSvc, settingsSearchSvc, environmentSvc, cfg) - handlers.RegisterJobSchedules(api, jobScheduleSvc, environmentSvc) - handlers.RegisterVolumes(api, dockerSvc, volumeSvc) - handlers.RegisterContainers(api, containerSvc, dockerSvc, settingsSvc) - handlers.RegisterPorts(api, portSvc) - handlers.RegisterNetworks(api, networkSvc, dockerSvc) - handlers.RegisterSwarm(api, swarmSvc, environmentSvc, eventSvc, cfg) - handlers.RegisterNotifications(api, notificationSvc, appriseSvc, cfg) - handlers.RegisterUpdater(api, updaterSvc) - handlers.RegisterCustomize(api, customizeSearchSvc) - handlers.RegisterSystem(api, dockerSvc, systemSvc, systemUpgradeSvc, cfg) - handlers.RegisterGitRepositories(api, gitRepositorySvc) - handlers.RegisterGitOpsSyncs(api, gitOpsSyncSvc) - handlers.RegisterWebhooks(api, webhookSvc) - handlers.RegisterVulnerability(api, vulnerabilitySvc) - handlers.RegisterDashboard(api, dashboardSvc) + handlers.RegisterAuth(api, svc.User, svc.Auth, svc.Oidc) + handlers.RegisterApiKeys(api, svc.ApiKey) + handlers.RegisterFederatedCredentials(api, svc.Federated) + handlers.RegisterRoles(api, svc.Role) + handlers.RegisterAppImages(api, svc.AppImages) + handlers.RegisterUsers(api, svc.User, svc.Auth) + handlers.RegisterProjects(api, svc.Project, svc.Activity, handlerAppCtx) + handlers.RegisterVersion(api, svc.Version) + handlers.RegisterEvents(api, svc.Event, svc.ApiKey) + handlers.RegisterActivities(api, svc.Activity, svc.Environment) + handlers.RegisterOidc(api, svc.Auth, svc.Oidc, svc.Role, svc.User, cfg) + handlers.RegisterEnvironments(api, svc.Environment, svc.Settings, svc.ApiKey, svc.Event, cfg) + handlers.RegisterContainerRegistries(api, svc.ContainerRegistry, svc.Environment) + handlers.RegisterTemplates(api, svc.Template, svc.Environment) + handlers.RegisterImages(api, svc.Docker, svc.Image, svc.ImageUpdate, svc.Settings, svc.Build, svc.Activity, handlerAppCtx) + handlers.RegisterBuildWorkspaces(api, svc.BuildWorkspace) + handlers.RegisterImageUpdates(api, svc.ImageUpdate, svc.Image, handlerAppCtx) + handlers.RegisterSettings(api, svc.Settings, svc.SettingsSearch, svc.Environment, cfg) + handlers.RegisterJobSchedules(api, svc.JobSchedule, svc.Environment) + handlers.RegisterVolumes(api, svc.Docker, svc.Volume, svc.Activity, handlerAppCtx) + handlers.RegisterContainers(api, svc.Container, svc.Docker, svc.Settings, svc.Activity, handlerAppCtx) + handlers.RegisterPorts(api, svc.Port) + handlers.RegisterNetworks(api, svc.Network, svc.Docker, svc.Activity, handlerAppCtx) + handlers.RegisterSwarm(api, svc.Swarm, svc.Environment, svc.Event, cfg) + handlers.RegisterNotifications(api, svc.Notification, cfg) + handlers.RegisterUpdater(api, svc.Updater, handlerAppCtx) + handlers.RegisterCustomize(api, svc.CustomizeSearch) + handlers.RegisterSystem(api, svc.Docker, svc.System, svc.SystemUpgrade, cfg, svc.Activity, handlerAppCtx) + handlers.RegisterDiagnostics(api, svc.Diagnostics) + handlers.RegisterGitRepositories(api, svc.GitRepository) + handlers.RegisterGitOpsSyncs(api, svc.GitOpsSync) + handlers.RegisterWebhooks(api, svc.Webhook) + handlers.RegisterVulnerability(api, svc.Vulnerability, handlerAppCtx) + handlers.RegisterDashboard(api, svc.Dashboard) } diff --git a/backend/api/api_test.go b/backend/api/api_test.go index 9caba8008d..b366f8e28f 100644 --- a/backend/api/api_test.go +++ b/backend/api/api_test.go @@ -118,8 +118,6 @@ func TestSetupAPIForSpec_PublicRoutesOverrideSecurity(t *testing.T) { {path: "/environments/pair", method: "POST"}, {path: "/version", method: "GET"}, {path: "/app-version", method: "GET"}, - {path: "/fonts/sans", method: "GET"}, - {path: "/fonts/mono", method: "GET"}, } for _, testCase := range testCases { diff --git a/backend/api/diagnostics.go b/backend/api/diagnostics.go deleted file mode 100644 index 383b42d4da..0000000000 --- a/backend/api/diagnostics.go +++ /dev/null @@ -1,43 +0,0 @@ -package api - -import ( - "net/http" - "runtime" - "time" - - "github.com/getarcaneapp/arcane/backend/api/ws" - "github.com/getarcaneapp/arcane/backend/internal/middleware" - wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" - "github.com/labstack/echo/v4" -) - -type DiagnosticsHandler struct { - wsMetrics *ws.WebSocketMetrics -} - -func RegisterDiagnosticsRoutes(group *echo.Group, authMiddleware *middleware.AuthMiddleware, wsMetrics *ws.WebSocketMetrics) { - h := &DiagnosticsHandler{wsMetrics: wsMetrics} - - diagnostics := group.Group("/diagnostics", authMiddleware.Add()) - diagnostics.GET("/ws", h.WebSocketDiagnostics) -} - -func (h *DiagnosticsHandler) WebSocketDiagnostics(c echo.Context) error { - val := c.Get("userIsAdmin") - if admin, ok := val.(bool); !ok || !admin { - return c.JSON(http.StatusForbidden, map[string]any{"error": "Admin access required"}) - } - - metrics := h.wsMetrics.Snapshot() - connections := h.wsMetrics.Connections() - - return c.JSON(http.StatusOK, map[string]any{ - "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "goroutines": runtime.NumGoroutine(), - "wsWorkerGoroutine": wshub.CountWorkerGoroutines(), - "gomaxprocs": runtime.GOMAXPROCS(0), - "goVersion": runtime.Version(), - "activeConnections": metrics, - "connections": connections, - }) -} diff --git a/backend/api/handlers/activities.go b/backend/api/handlers/activities.go new file mode 100644 index 0000000000..890584e1d5 --- /dev/null +++ b/backend/api/handlers/activities.go @@ -0,0 +1,572 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" + "github.com/getarcaneapp/arcane/types/activity" + "github.com/getarcaneapp/arcane/types/base" + "gorm.io/gorm" +) + +type ActivityHandler struct { + activityService *services.ActivityService + environmentService *services.EnvironmentService +} + +type ListActivitiesInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + Search string `query:"search" doc:"Search query"` + Sort string `query:"sort" doc:"Column to sort by"` + Order string `query:"order" default:"desc" doc:"Sort direction"` + Start int `query:"start" default:"0" doc:"Start index"` + Limit int `query:"limit" default:"50" doc:"Limit"` + Status string `query:"status" doc:"Filter by activity status"` + Type string `query:"type" doc:"Filter by activity type"` + ResourceType string `query:"resourceType" doc:"Filter by resource type"` +} + +type ListActivitiesOutput struct { + Body base.Paginated[activity.Activity] +} + +type GetActivityInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + ActivityID string `path:"activityId" doc:"Activity ID"` + Limit int `query:"limit" default:"500" doc:"Maximum messages to return"` +} + +type GetActivityOutput struct { + Body base.ApiResponse[activity.Detail] +} + +type ClearActivityHistoryInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` +} + +type ClearActivityHistoryOutput struct { + Body base.ApiResponse[activity.ClearHistoryResult] +} + +type StreamActivitiesInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + Limit int `query:"limit" default:"50" doc:"Initial snapshot limit"` +} + +type CancelActivityInput struct { + EnvironmentID string `path:"id" doc:"Environment ID"` + ActivityID string `path:"activityId" doc:"Activity ID"` + RequestedBy string `query:"requestedBy" doc:"Display name to attribute the cancellation to (used when proxying to a remote environment)"` +} + +type CancelActivityOutput struct { + Body base.ApiResponse[activity.Activity] +} + +func RegisterActivities(api huma.API, activityService *services.ActivityService, environmentService *services.EnvironmentService) { + h := &ActivityHandler{ + activityService: activityService, + environmentService: environmentService, + } + + huma.Register(api, huma.Operation{ + OperationID: "list-activities", + Method: http.MethodGet, + Path: "/environments/{id}/activities", + Summary: "List background activities", + Description: "Get current and recent background activities for an environment", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermActivitiesRead), + }, h.ListActivities) + + huma.Register(api, huma.Operation{ + OperationID: "get-activity", + Method: http.MethodGet, + Path: "/environments/{id}/activities/{activityId}", + Summary: "Get background activity", + Description: "Get a background activity with its recent output messages", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermActivitiesRead), + }, h.GetActivity) + + huma.Register(api, huma.Operation{ + OperationID: "stream-activities", + Method: http.MethodGet, + Path: "/environments/{id}/activities/stream", + Summary: "Stream background activities", + Description: "Stream background activity updates as JSON lines", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermActivitiesRead), + }, h.StreamActivities) + + huma.Register(api, huma.Operation{ + OperationID: "cancel-activity", + Method: http.MethodPost, + Path: "/environments/{id}/activities/{activityId}/cancel", + Summary: "Cancel a background activity", + Description: "Request cancellation of a running or queued background activity", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermActivitiesCancel), + }, h.CancelActivity) + + huma.Register(api, huma.Operation{ + OperationID: "clear-activity-history", + Method: http.MethodDelete, + Path: "/environments/{id}/activities/history", + Summary: "Clear background activity history", + Description: "Delete completed background activity history for an environment", + Tags: []string{"Activities"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermActivitiesDelete), + }, h.ClearHistory) +} + +func (h *ActivityHandler) ListActivities(ctx context.Context, input *ListActivitiesInput) (*ListActivitiesOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyListActivitiesInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) + if input.Status != "" { + params.Filters["status"] = input.Status + } + if input.Type != "" { + params.Filters["type"] = input.Type + } + if input.ResourceType != "" { + params.Filters["resourceType"] = input.ResourceType + } + + activities, paginationResp, err := h.activityService.ListActivitiesPaginated(ctx, input.EnvironmentID, params) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, activities) + + return &ListActivitiesOutput{ + Body: base.Paginated[activity.Activity]{ + Success: true, + Data: activities, + Pagination: base.PaginationResponse{ + TotalPages: paginationResp.TotalPages, + TotalItems: paginationResp.TotalItems, + CurrentPage: paginationResp.CurrentPage, + ItemsPerPage: paginationResp.ItemsPerPage, + GrandTotalItems: paginationResp.GrandTotalItems, + }, + }, + }, nil +} + +func (h *ActivityHandler) GetActivity(ctx context.Context, input *GetActivityInput) (*GetActivityOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyGetActivityInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if input.ActivityID == "" { + return nil, huma.Error400BadRequest("activity id is required") + } + + detail, err := h.activityService.GetActivityDetail(ctx, input.EnvironmentID, input.ActivityID, input.Limit) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, huma.Error404NotFound("activity not found") + } + return nil, huma.Error500InternalServerError(err.Error()) + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &detail.Activity) + + return &GetActivityOutput{ + Body: base.ApiResponse[activity.Detail]{ + Success: true, + Data: *detail, + }, + }, nil +} + +func (h *ActivityHandler) StreamActivities(ctx context.Context, input *StreamActivitiesInput) (*huma.StreamResponse, error) { + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + return &huma.StreamResponse{ + Body: func(humaCtx huma.Context) { //nolint:contextcheck // streaming work must use humaCtx.Context() + httpx.SetJSONStreamHeaders(humaCtx) + + writer := humaCtx.BodyWriter() + encoder := json.NewEncoder(writer) + flush := func() { + if f, ok := writer.(http.Flusher); ok { + f.Flush() + } + } + + if input.EnvironmentID != "0" { + h.streamRemoteActivitySnapshotsInternal(humaCtx.Context(), input, encoder, flush) + return + } + + h.streamLocalActivitiesInternal(humaCtx.Context(), input, encoder, flush) + }, + }, nil +} + +func (h *ActivityHandler) streamLocalActivitiesInternal( + ctx context.Context, + input *StreamActivitiesInput, + encoder *json.Encoder, + flush func(), +) { + sendSnapshot := func() bool { + activities, _, err := h.activityService.ListActivitiesPaginated(ctx, input.EnvironmentID, pagination.QueryParams{ + Params: pagination.Params{Limit: resolveActivityStreamLimitInternal(input.Limit)}, + }) + if err != nil { + return false + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, activities) + if err := encoder.Encode(activity.StreamEvent{ + Type: "snapshot", + Activities: activities, + Timestamp: time.Now(), + }); err != nil { + return false + } + flush() + return true + } + if !sendSnapshot() { + return + } + + events, missedEvents, unsubscribe := h.activityService.Subscribe(input.EnvironmentID) + defer unsubscribe() + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + h.applyActivityStreamEventSourceLabelInternal(ctx, input.EnvironmentID, &event) + if err := encoder.Encode(event); err != nil { + return + } + flush() + case <-ticker.C: + if missedEvents() && !sendSnapshot() { + return + } + if err := encoder.Encode(activity.StreamEvent{ + Type: "heartbeat", + Timestamp: time.Now(), + }); err != nil { + return + } + flush() + } + } +} + +func (h *ActivityHandler) ClearHistory(ctx context.Context, input *ClearActivityHistoryInput) (*ClearActivityHistoryOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyClearHistoryInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + deleted, err := h.activityService.DeleteHistory(ctx, input.EnvironmentID) + if err != nil { + return nil, huma.Error500InternalServerError(err.Error()) + } + + return &ClearActivityHistoryOutput{ + Body: base.ApiResponse[activity.ClearHistoryResult]{ + Success: true, + Data: activity.ClearHistoryResult{Deleted: deleted}, + }, + }, nil +} + +func (h *ActivityHandler) CancelActivity(ctx context.Context, input *CancelActivityInput) (*CancelActivityOutput, error) { + if input.EnvironmentID != "0" { + return h.proxyCancelActivityInternal(ctx, input) + } + if h.activityService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if input.ActivityID == "" { + return nil, huma.Error400BadRequest("activity id is required") + } + + requestedBy := h.cancelRequestedByInternal(ctx, input.RequestedBy) + cancelled, err := h.activityService.CancelActivity(ctx, input.EnvironmentID, input.ActivityID, requestedBy) + if err != nil { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return nil, huma.Error404NotFound("activity not found") + case errors.Is(err, services.ErrActivityNotCancelable): + return nil, huma.Error409Conflict("activity is not running") + default: + return nil, huma.Error500InternalServerError(err.Error()) + } + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, cancelled) + + return &CancelActivityOutput{ + Body: base.ApiResponse[activity.Activity]{ + Success: true, + Data: *cancelled, + }, + }, nil +} + +func (h *ActivityHandler) proxyCancelActivityInternal(ctx context.Context, input *CancelActivityInput) (*CancelActivityOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + path := fmt.Sprintf("/api/environments/0/activities/%s/cancel", url.PathEscape(input.ActivityID)) + if requestedBy := h.cancelRequestedByInternal(ctx, input.RequestedBy); requestedBy != "" { + path += "?requestedBy=" + url.QueryEscape(requestedBy) + } + out, err := proxyRemoteJSONInternal[base.ApiResponse[activity.Activity]](ctx, h.environmentService, input.EnvironmentID, http.MethodPost, path, nil) + if err != nil { + return nil, err + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &out.Data) + return &CancelActivityOutput{Body: *out}, nil +} + +// cancelRequestedByInternal resolves a human-readable name for the cancellation +// audit message, preferring the authenticated user and falling back to a name +// forwarded from a proxying controller. +func (h *ActivityHandler) cancelRequestedByInternal(ctx context.Context, forwarded string) string { + if user, ok := humamw.GetCurrentUserFromContext(ctx); ok && user != nil { + if user.DisplayName != nil && strings.TrimSpace(*user.DisplayName) != "" { + return strings.TrimSpace(*user.DisplayName) + } + if name := strings.TrimSpace(user.Username); name != "" { + return name + } + } + return strings.TrimSpace(forwarded) +} + +func (h *ActivityHandler) streamRemoteActivitySnapshotsInternal( + ctx context.Context, + input *StreamActivitiesInput, + encoder *json.Encoder, + flush func(), +) { + pollTicker := time.NewTicker(5 * time.Second) + defer pollTicker.Stop() + heartbeatTicker := time.NewTicker(15 * time.Second) + defer heartbeatTicker.Stop() + + sendSnapshot := func(ctx context.Context) bool { + output, err := h.proxyListActivitiesInternal(ctx, &ListActivitiesInput{ + EnvironmentID: input.EnvironmentID, + Start: 0, + Limit: resolveActivityStreamLimitInternal(input.Limit), + Order: "desc", + }) + if err != nil { + return false + } + if err := encoder.Encode(activity.StreamEvent{ + Type: "snapshot", + Activities: output.Body.Data, + Timestamp: time.Now(), + }); err != nil { + return false + } + flush() + return true + } + + if !sendSnapshot(ctx) { + return + } + + for { + select { + case <-ctx.Done(): + return + case <-pollTicker.C: + if !sendSnapshot(ctx) { + return + } + case <-heartbeatTicker.C: + if err := encoder.Encode(activity.StreamEvent{ + Type: "heartbeat", + Timestamp: time.Now(), + }); err != nil { + return + } + flush() + } + } +} + +func (h *ActivityHandler) proxyListActivitiesInternal(ctx context.Context, input *ListActivitiesInput) (*ListActivitiesOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + path := "/api/environments/0/activities?" + activityListQueryInternal(input).Encode() + out, err := proxyRemoteJSONInternal[base.Paginated[activity.Activity]](ctx, h.environmentService, input.EnvironmentID, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + h.applyActivitySourceLabelsInternal(ctx, input.EnvironmentID, out.Data) + return &ListActivitiesOutput{Body: *out}, nil +} + +func (h *ActivityHandler) proxyGetActivityInternal(ctx context.Context, input *GetActivityInput) (*GetActivityOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + path := fmt.Sprintf("/api/environments/0/activities/%s?limit=%d", url.PathEscape(input.ActivityID), input.Limit) + out, err := proxyRemoteJSONInternal[base.ApiResponse[activity.Detail]](ctx, h.environmentService, input.EnvironmentID, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &out.Data.Activity) + return &GetActivityOutput{Body: *out}, nil +} + +func (h *ActivityHandler) proxyClearHistoryInternal(ctx context.Context, input *ClearActivityHistoryInput) (*ClearActivityHistoryOutput, error) { + if h.environmentService == nil { + return nil, huma.Error500InternalServerError("environment service not available") + } + out, err := proxyRemoteJSONInternal[base.ApiResponse[activity.ClearHistoryResult]](ctx, h.environmentService, input.EnvironmentID, http.MethodDelete, "/api/environments/0/activities/history", nil) + if err != nil { + return nil, err + } + return &ClearActivityHistoryOutput{Body: *out}, nil +} + +func (h *ActivityHandler) applyActivitySourceLabelsInternal(ctx context.Context, environmentID string, activities []activity.Activity) { + sourceID, sourceName := h.resolveActivitySourceInternal(ctx, environmentID) + for i := range activities { + applyActivitySourceInternal(&activities[i], sourceID, sourceName) + } +} + +func (h *ActivityHandler) applyActivitySourceLabelInternal(ctx context.Context, environmentID string, item *activity.Activity) { + sourceID, sourceName := h.resolveActivitySourceInternal(ctx, environmentID) + applyActivitySourceInternal(item, sourceID, sourceName) +} + +func (h *ActivityHandler) applyActivityStreamEventSourceLabelInternal(ctx context.Context, environmentID string, event *activity.StreamEvent) { + if event == nil { + return + } + sourceID, sourceName := h.resolveActivitySourceInternal(ctx, environmentID) + if event.Activity != nil { + applyActivitySourceInternal(event.Activity, sourceID, sourceName) + } + for i := range event.Activities { + applyActivitySourceInternal(&event.Activities[i], sourceID, sourceName) + } +} + +func (h *ActivityHandler) resolveActivitySourceInternal(ctx context.Context, environmentID string) (string, string) { + if environmentID == "" { + environmentID = "0" + } + if h.environmentService != nil { + if env, err := h.environmentService.GetEnvironmentByID(ctx, environmentID); err == nil && env != nil { + return env.ID, env.Name + } + } + if environmentID == "0" { + return "0", "Local" + } + return environmentID, environmentID +} + +func applyActivitySourceInternal(item *activity.Activity, sourceID, sourceName string) { + if item == nil { + return + } + item.SourceEnvironmentID = sourceID + item.SourceEnvironmentName = sourceName +} + +func activityListQueryInternal(input *ListActivitiesInput) url.Values { + values := url.Values{} + values.Set("start", strconv.Itoa(input.Start)) + values.Set("limit", strconv.Itoa(input.Limit)) + if input.Search != "" { + values.Set("search", input.Search) + } + if input.Sort != "" { + values.Set("sort", input.Sort) + } + if input.Order != "" { + values.Set("order", input.Order) + } + if input.Status != "" { + values.Set("status", input.Status) + } + if input.Type != "" { + values.Set("type", input.Type) + } + if input.ResourceType != "" { + values.Set("resourceType", input.ResourceType) + } + return values +} + +func resolveActivityStreamLimitInternal(limit int) int { + if limit <= 0 { + return 50 + } + if limit > 100 { + return 100 + } + return limit +} diff --git a/backend/api/handlers/activities_test.go b/backend/api/handlers/activities_test.go new file mode 100644 index 0000000000..f9be5ff530 --- /dev/null +++ b/backend/api/handlers/activities_test.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/internal/services" +) + +func setupActivityHandlerTestDBInternal(t *testing.T) *database.DB { + t.Helper() + + db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.Activity{}, + &models.ActivityMessage{}, + &models.Environment{}, + &models.SettingVariable{}, + )) + return &database.DB{DB: db} +} + +func TestActivityHandlerClearHistoryDeletesSelectedEnvironmentOnlyInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityHandlerTestDBInternal(t) + activityService := services.NewActivityService(db) + handler := &ActivityHandler{activityService: activityService} + + completed, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = activityService.CompleteActivity(ctx, completed.ID, models.ActivityStatusSuccess, "done", nil) + require.NoError(t, err) + + running, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + remoteCompleted, err := activityService.StartActivity(ctx, services.StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = activityService.CompleteActivity(ctx, remoteCompleted.ID, models.ActivityStatusSuccess, "done", nil) + require.NoError(t, err) + + out, err := handler.ClearHistory(ctx, &ClearActivityHistoryInput{EnvironmentID: "0"}) + require.NoError(t, err) + require.EqualValues(t, 1, out.Body.Data.Deleted) + + var remaining []models.Activity + require.NoError(t, db.Find(&remaining).Error) + require.Len(t, remaining, 2) + require.ElementsMatch(t, []string{running.ID, remoteCompleted.ID}, []string{remaining[0].ID, remaining[1].ID}) +} + +func TestActivityHandlerClearHistoryProxiesRemoteEnvironmentInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityHandlerTestDBInternal(t) + settingsService, err := services.NewSettingsService(ctx, db) + require.NoError(t, err) + + token := "remote-token" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/api/environments/0/activities/history", r.URL.Path) + require.Equal(t, token, r.Header.Get("X-API-Key")) + require.Equal(t, token, r.Header.Get("X-Arcane-Agent-Token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"deleted":7}}`)) + })) + defer server.Close() + + now := time.Now() + require.NoError(t, db.Create(&models.Environment{ + BaseModel: models.BaseModel{ + ID: "remote-1", + CreatedAt: now, + UpdatedAt: &now, + }, + Name: "Remote", + ApiUrl: server.URL, + Status: string(models.EnvironmentStatusOnline), + Enabled: true, + AccessToken: &token, + }).Error) + + handler := &ActivityHandler{ + environmentService: services.NewEnvironmentService(db, server.Client(), nil, nil, settingsService, nil), + } + + out, err := handler.ClearHistory(ctx, &ClearActivityHistoryInput{EnvironmentID: "remote-1"}) + require.NoError(t, err) + require.EqualValues(t, 7, out.Body.Data.Deleted) +} diff --git a/backend/api/handlers/apikeys.go b/backend/api/handlers/apikeys.go index f0fb858fa1..e6777ade06 100644 --- a/backend/api/handlers/apikeys.go +++ b/backend/api/handlers/apikeys.go @@ -9,6 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/apikey" "github.com/getarcaneapp/arcane/types/base" @@ -73,6 +74,10 @@ type DeleteApiKeyOutput struct { Body base.ApiResponse[base.MessageResponse] } +type ListMyApiKeysOutput struct { + Body base.ApiResponse[[]apikey.ApiKey] +} + // RegisterApiKeys registers API key management routes using Huma. func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { h := &ApiKeyHandler{ @@ -90,7 +95,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysList), }, h.ListApiKeys) huma.Register(api, huma.Operation{ @@ -104,7 +109,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysCreate), }, h.CreateApiKey) huma.Register(api, huma.Operation{ @@ -118,7 +123,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysRead), }, h.GetApiKey) huma.Register(api, huma.Operation{ @@ -132,7 +137,7 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysUpdate), }, h.UpdateApiKey) huma.Register(api, huma.Operation{ @@ -146,8 +151,49 @@ func RegisterApiKeys(api huma.API, apiKeyService *services.ApiKeyService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermApiKeysDelete), }, h.DeleteApiKey) + + // Self-service endpoints — no admin permission required, scoped to the + // caller's own keys via current-user context. + huma.Register(api, huma.Operation{ + OperationID: "list-my-api-keys", + Method: http.MethodGet, + Path: "/auth/me/api-keys", + Summary: "List my API keys", + Description: "List API keys owned by the current user", + Tags: []string{"API Keys"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.ListMyApiKeys) + + huma.Register(api, huma.Operation{ + OperationID: "create-my-api-key", + Method: http.MethodPost, + Path: "/auth/me/api-keys", + Summary: "Create my API key", + Description: "Create a new API key owned by the current user", + Tags: []string{"API Keys"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.CreateMyApiKey) + + huma.Register(api, huma.Operation{ + OperationID: "delete-my-api-key", + Method: http.MethodDelete, + Path: "/auth/me/api-keys/{id}", + Summary: "Delete my API key", + Description: "Delete one of the current user's own API keys", + Tags: []string{"API Keys"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.DeleteMyApiKey) } // ListApiKeys returns a paginated list of API keys. @@ -156,11 +202,6 @@ func (h *ApiKeyHandler) ListApiKeys(ctx context.Context, input *ListApiKeysInput return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - params := pagination.QueryParams{ SearchQuery: pagination.SearchQuery{ Search: input.Search, @@ -169,7 +210,7 @@ func (h *ApiKeyHandler) ListApiKeys(ctx context.Context, input *ListApiKeysInput Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -197,31 +238,7 @@ func (h *ApiKeyHandler) ListApiKeys(ctx context.Context, input *ListApiKeysInput // CreateApiKey creates a new API key. func (h *ApiKeyHandler) CreateApiKey(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { - if h.apiKeyService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) - } - - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - apiKey, err := h.apiKeyService.CreateApiKey(ctx, user.ID, input.Body) - if err != nil { - return nil, huma.Error500InternalServerError((&common.ApiKeyCreationError{Err: err}).Error()) - } - - return &CreateApiKeyOutput{ - Body: base.ApiResponse[apikey.ApiKeyCreatedDto]{ - Success: true, - Data: *apiKey, - }, - }, nil + return h.createCurrentUserApiKeyInternal(ctx, input) } // GetApiKey returns details of a specific API key. @@ -230,11 +247,6 @@ func (h *ApiKeyHandler) GetApiKey(ctx context.Context, input *GetApiKeyInput) (* return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - apiKey, err := h.apiKeyService.GetApiKey(ctx, input.ID) if err != nil { return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) @@ -254,12 +266,12 @@ func (h *ApiKeyHandler) UpdateApiKey(ctx context.Context, input *UpdateApiKeyInp return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - apiKey, err := h.apiKeyService.UpdateApiKey(ctx, input.ID, input.Body) + apiKey, err := h.apiKeyService.UpdateApiKey(ctx, user.ID, input.ID, input.Body) if err != nil { if errors.Is(err, services.ErrApiKeyNotFound) { return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) @@ -267,6 +279,9 @@ func (h *ApiKeyHandler) UpdateApiKey(ctx context.Context, input *UpdateApiKeyInp if errors.Is(err, services.ErrApiKeyProtected) { return nil, huma.Error403Forbidden("static API keys cannot be updated") } + if errors.Is(err, services.ErrApiKeyPermissionEscalation) { + return nil, huma.Error403Forbidden(err.Error()) + } return nil, huma.Error500InternalServerError((&common.ApiKeyUpdateError{Err: err}).Error()) } @@ -284,9 +299,100 @@ func (h *ApiKeyHandler) DeleteApiKey(ctx context.Context, input *DeleteApiKeyInp return nil, huma.Error500InternalServerError("service not available") } - // Check admin access - if err := checkAdminInternal(ctx); err != nil { - return nil, err + if err := h.apiKeyService.DeleteApiKey(ctx, input.ID); err != nil { + if errors.Is(err, services.ErrApiKeyNotFound) { + return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) + } + if errors.Is(err, services.ErrApiKeyProtected) { + return nil, huma.Error403Forbidden("static API keys cannot be deleted") + } + return nil, huma.Error500InternalServerError((&common.ApiKeyDeletionError{Err: err}).Error()) + } + + return &DeleteApiKeyOutput{ + Body: base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: base.MessageResponse{ + Message: "API key deleted successfully", + }, + }, + }, nil +} + +// ListMyApiKeys lists API keys owned by the current user (self-service). +func (h *ApiKeyHandler) ListMyApiKeys(ctx context.Context, input *struct{}) (*ListMyApiKeysOutput, error) { + if h.apiKeyService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + keys, err := h.apiKeyService.ListApiKeysByUser(ctx, user.ID) + if err != nil { + return nil, huma.Error500InternalServerError((&common.ApiKeyListError{Err: err}).Error()) + } + + return &ListMyApiKeysOutput{ + Body: base.ApiResponse[[]apikey.ApiKey]{ + Success: true, + Data: keys, + }, + }, nil +} + +// CreateMyApiKey creates a new API key owned by the current user (self-service). +func (h *ApiKeyHandler) CreateMyApiKey(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { + return h.createCurrentUserApiKeyInternal(ctx, input) +} + +func (h *ApiKeyHandler) createCurrentUserApiKeyInternal(ctx context.Context, input *CreateApiKeyInput) (*CreateApiKeyOutput, error) { + if h.apiKeyService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + apiKey, err := h.apiKeyService.CreateApiKey(ctx, user.ID, input.Body) + if err != nil { + if errors.Is(err, services.ErrApiKeyPermissionEscalation) { + return nil, huma.Error403Forbidden(err.Error()) + } + return nil, huma.Error500InternalServerError((&common.ApiKeyCreationError{Err: err}).Error()) + } + + return &CreateApiKeyOutput{ + Body: base.ApiResponse[apikey.ApiKeyCreatedDto]{ + Success: true, + Data: *apiKey, + }, + }, nil +} + +// DeleteMyApiKey deletes one of the current user's API keys, validating +// ownership before removal so the endpoint can't be used to delete other +// users' keys. +func (h *ApiKeyHandler) DeleteMyApiKey(ctx context.Context, input *DeleteApiKeyInput) (*DeleteApiKeyOutput, error) { + if h.apiKeyService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + existing, err := h.apiKeyService.GetApiKey(ctx, input.ID) + if err != nil { + return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) + } + if existing.UserID == nil || *existing.UserID != user.ID { + return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) } if err := h.apiKeyService.DeleteApiKey(ctx, input.ID); err != nil { @@ -294,7 +400,7 @@ func (h *ApiKeyHandler) DeleteApiKey(ctx context.Context, input *DeleteApiKeyInp return nil, huma.Error404NotFound((&common.ApiKeyNotFoundError{}).Error()) } if errors.Is(err, services.ErrApiKeyProtected) { - return nil, huma.Error403Forbidden("static API keys cannot be deleted") + return nil, huma.Error403Forbidden("this API key cannot be deleted") } return nil, huma.Error500InternalServerError((&common.ApiKeyDeletionError{Err: err}).Error()) } diff --git a/backend/api/handlers/auth.go b/backend/api/handlers/auth.go index 38907d3a16..598582f6c3 100644 --- a/backend/api/handlers/auth.go +++ b/backend/api/handlers/auth.go @@ -12,7 +12,6 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/auth" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/user" @@ -60,6 +59,22 @@ type ChangePasswordOutput struct { Body base.ApiResponse[base.MessageResponse] } +type LogoutAllOtherSessionsOutput struct { + Body base.ApiResponse[base.MessageResponse] +} + +type UpdateMyProfileInput struct { + Body struct { + DisplayName *string `json:"displayName,omitempty"` + Email *string `json:"email,omitempty"` + Locale *string `json:"locale,omitempty"` + } +} + +type UpdateMyProfileOutput struct { + Body base.ApiResponse[user.User] +} + type GetCurrentUserOutput struct { Body base.ApiResponse[user.User] } @@ -127,6 +142,32 @@ func RegisterAuth(api huma.API, userService *services.UserService, authService * {"ApiKeyAuth": {}}, }, }, h.ChangePassword) + + huma.Register(api, huma.Operation{ + OperationID: "logout-all-other-sessions", + Method: http.MethodPost, + Path: "/auth/sessions/logout-all", + Summary: "Logout all other sessions", + Description: "Revoke every session for the current user except the one making this request", + Tags: []string{"Auth"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.LogoutAllOtherSessions) + + huma.Register(api, huma.Operation{ + OperationID: "update-my-profile", + Method: http.MethodPut, + Path: "/auth/me/profile", + Summary: "Update own profile", + Description: "Update the current user's display name and email. Forbidden for OIDC-managed accounts.", + Tags: []string{"Auth"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + }, h.UpdateMyProfile) } // Login authenticates a user and returns tokens. @@ -155,9 +196,9 @@ func (h *AuthHandler) Login(ctx context.Context, input *LoginInput) (*LoginOutpu } } - var userResp user.User - if mapErr := mapper.MapStruct(userModel, &userResp); mapErr != nil { - return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: mapErr}).Error()) + userResp, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) } maxAge := max(int(time.Until(tokenPair.ExpiresAt).Seconds()), 0) @@ -202,6 +243,8 @@ func (h *AuthHandler) Logout(ctx context.Context, input *struct{}) (*LogoutOutpu } // GetCurrentUser returns the currently authenticated user's information. +// Uses ToUserResponseDto (not the generic struct mapper) so the RBAC fields +// (RoleAssignments, PermissionsByEnv) are resolved via RoleService. func (h *AuthHandler) GetCurrentUser(ctx context.Context, input *struct{}) (*GetCurrentUserOutput, error) { if h.userService == nil { return nil, huma.Error500InternalServerError("service not available") @@ -217,9 +260,9 @@ func (h *AuthHandler) GetCurrentUser(ctx context.Context, input *struct{}) (*Get return nil, huma.Error500InternalServerError((&common.UserRetrievalError{Err: err}).Error()) } - var out user.User - if mapErr := mapper.MapStruct(userModel, &out); mapErr != nil { - return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: mapErr}).Error()) + out, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) } return &GetCurrentUserOutput{ @@ -304,3 +347,85 @@ func (h *AuthHandler) ChangePassword(ctx context.Context, input *ChangePasswordI }, }, nil } + +// LogoutAllOtherSessions revokes every active session for the current user +// except the session making this request. +func (h *AuthHandler) LogoutAllOtherSessions(ctx context.Context, input *struct{}) (*LogoutAllOtherSessionsOutput, error) { + if h.authService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + userModel, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + currentSessionID, _ := humamw.GetCurrentSessionIDFromContext(ctx) + if err := h.authService.LogoutAllOtherSessions(ctx, userModel.ID, currentSessionID); err != nil { + return nil, huma.Error500InternalServerError("failed to revoke sessions: " + err.Error()) + } + + return &LogoutAllOtherSessionsOutput{ + Body: base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: base.MessageResponse{ + Message: "All other sessions signed out", + }, + }, + }, nil +} + +// UpdateMyProfile lets the current user update their own displayName and email. +// OIDC-managed accounts are read-only here. +func (h *AuthHandler) UpdateMyProfile(ctx context.Context, input *UpdateMyProfileInput) (*UpdateMyProfileOutput, error) { + if h.userService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + currentUser, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + isOidcUser := currentUser.OidcSubjectId != nil && *currentUser.OidcSubjectId != "" + touchesIdpFields := input.Body.DisplayName != nil || input.Body.Email != nil + if isOidcUser && touchesIdpFields { + return nil, huma.Error403Forbidden("display name and email are managed by your identity provider") + } + + userModel, err := h.userService.GetUser(ctx, currentUser.ID) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserRetrievalError{Err: err}).Error()) + } + + if input.Body.DisplayName != nil { + userModel.DisplayName = input.Body.DisplayName + } + if input.Body.Email != nil { + normalized, err := normalizeOptionalEmailInternal(input.Body.Email) + if err != nil { + return nil, huma.Error400BadRequest(err.Error()) + } + userModel.Email = normalized + } + if input.Body.Locale != nil { + userModel.Locale = input.Body.Locale + } + + updated, err := h.userService.UpdateUser(ctx, userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserUpdateError{Err: err}).Error()) + } + + out, err := h.userService.ToUserResponseDto(ctx, *updated) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) + } + + return &UpdateMyProfileOutput{ + Body: base.ApiResponse[user.User]{ + Success: true, + Data: out, + }, + }, nil +} diff --git a/backend/api/handlers/build_workspaces.go b/backend/api/handlers/build_workspaces.go index 2426258111..efaee82c27 100644 --- a/backend/api/handlers/build_workspaces.go +++ b/backend/api/handlers/build_workspaces.go @@ -10,6 +10,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" volumetypes "github.com/getarcaneapp/arcane/types/volume" ) @@ -31,6 +32,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "List files and directories under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.BrowseDirectory) huma.Register(api, huma.Operation{ @@ -41,6 +43,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Read file content under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.GetFileContent) huma.Register(api, huma.Operation{ @@ -51,6 +54,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Download a file from the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.DownloadFile) huma.Register(api, huma.Operation{ @@ -78,7 +82,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.UploadFile) huma.Register(api, huma.Operation{ @@ -89,7 +93,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Create a directory under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.CreateDirectory) huma.Register(api, huma.Operation{ @@ -100,7 +104,7 @@ func RegisterBuildWorkspaces(api huma.API, workspaceService *services.BuildWorks Description: "Delete a file or directory under the builds workspace root", Tags: []string{"Builds"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermBuildWorkspacesManage), }, h.DeleteFile) } @@ -198,9 +202,6 @@ func (h *BuildWorkspaceHandler) DownloadFile(ctx context.Context, input *Downloa } func (h *BuildWorkspaceHandler) UploadFile(ctx context.Context, input *UploadBuildFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.service == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -227,9 +228,6 @@ func (h *BuildWorkspaceHandler) UploadFile(ctx context.Context, input *UploadBui } func (h *BuildWorkspaceHandler) CreateDirectory(ctx context.Context, input *CreateBuildDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.service == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -243,9 +241,6 @@ func (h *BuildWorkspaceHandler) CreateDirectory(ctx context.Context, input *Crea } func (h *BuildWorkspaceHandler) DeleteFile(ctx context.Context, input *DeleteBuildFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.service == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/container_registries.go b/backend/api/handlers/container_registries.go index 010104329b..020f97a845 100644 --- a/backend/api/handlers/container_registries.go +++ b/backend/api/handlers/container_registries.go @@ -10,6 +10,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" @@ -120,6 +121,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesList), }, h.ListRegistries) huma.Register(api, huma.Operation{ @@ -133,7 +135,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesCreate), }, h.CreateRegistry) huma.Register(api, huma.Operation{ @@ -147,7 +149,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesUpdate), }, h.SyncRegistries) huma.Register(api, huma.Operation{ @@ -161,6 +163,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesRead), }, h.GetPullUsage) huma.Register(api, huma.Operation{ @@ -174,6 +177,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesRead), }, h.GetRegistry) huma.Register(api, huma.Operation{ @@ -187,7 +191,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesUpdate), }, h.UpdateRegistry) huma.Register(api, huma.Operation{ @@ -201,7 +205,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesDelete), }, h.DeleteRegistry) huma.Register(api, huma.Operation{ @@ -215,7 +219,7 @@ func RegisterContainerRegistries(api huma.API, registryService *services.Contain {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermRegistriesTest), }, h.TestRegistry) } @@ -229,7 +233,7 @@ func (h *ContainerRegistryHandler) ListRegistries(ctx context.Context, input *Li return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) registries, paginationResp, err := h.registryService.GetRegistriesPaginated(ctx, params) if err != nil { @@ -276,10 +280,6 @@ func (h *ContainerRegistryHandler) CreateRegistry(ctx context.Context, input *Cr return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - reg, err := h.registryService.CreateRegistry(ctx, input.Body) if err != nil { apiErr := models.ToAPIError(err) @@ -332,10 +332,6 @@ func (h *ContainerRegistryHandler) UpdateRegistry(ctx context.Context, input *Up return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - reg, err := h.registryService.UpdateRegistry(ctx, input.ID, input.Body) if err != nil { apiErr := models.ToAPIError(err) @@ -363,10 +359,6 @@ func (h *ContainerRegistryHandler) DeleteRegistry(ctx context.Context, input *De return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.registryService.DeleteRegistry(ctx, input.ID); err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.RegistryDeletionError{Err: err}).Error()) @@ -390,10 +382,6 @@ func (h *ContainerRegistryHandler) TestRegistry(ctx context.Context, input *Test return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - reg, err := h.registryService.GetRegistryByID(ctx, input.ID) if err != nil { apiErr := models.ToAPIError(err) @@ -445,10 +433,6 @@ func (h *ContainerRegistryHandler) SyncRegistries(ctx context.Context, input *Sy return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.registryService.SyncRegistries(ctx, input.Body.Registries); err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.RegistrySyncError{Err: err}).Error()) @@ -468,7 +452,7 @@ func (h *ContainerRegistryHandler) SyncRegistries(ctx context.Context, input *Sy // Helper Methods // ============================================================================ -func (h *ContainerRegistryHandler) triggerRemoteRegistrySync(ctx context.Context, reason string) { //nolint:contextcheck // intentionally spawns background sync +func (h *ContainerRegistryHandler) triggerRemoteRegistrySync(ctx context.Context, reason string) { if h.environmentService == nil { return } diff --git a/backend/api/handlers/containers.go b/backend/api/handlers/containers.go index fd4ddfa775..55611364f8 100644 --- a/backend/api/handlers/containers.go +++ b/backend/api/handlers/containers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "io" "maps" "net/http" "net/netip" @@ -10,9 +11,14 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/projects" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" dockercontainer "github.com/moby/moby/api/types/container" @@ -23,9 +29,11 @@ type ContainerHandler struct { containerService *services.ContainerService dockerService *services.DockerClientService settingsService *services.SettingsService + activityService *services.ActivityService + appCtx context.Context } -// Paginated response +// ContainerPaginatedResponse is the paginated list response for containers. type ContainerPaginatedResponse struct { Success bool `json:"success"` Data []containertypes.Summary `json:"data"` @@ -122,7 +130,7 @@ type DeleteContainerOutput struct { Body ContainerActionResponse } -// RegisterContainers registers container endpoints. +// SetAutoUpdateInput is the request input for toggling container auto-update. type SetAutoUpdateInput struct { EnvironmentID string `path:"id" doc:"Environment ID"` ContainerID string `path:"containerId" doc:"Container ID"` @@ -135,11 +143,13 @@ type SetAutoUpdateOutput struct { Body ContainerActionResponse } -func RegisterContainers(api huma.API, containerSvc *services.ContainerService, dockerSvc *services.DockerClientService, settingsSvc *services.SettingsService) { +func RegisterContainers(api huma.API, containerSvc *services.ContainerService, dockerSvc *services.DockerClientService, settingsSvc *services.SettingsService, activitySvc *services.ActivityService, appCtx ActivityAppContext) { h := &ContainerHandler{ containerService: containerSvc, dockerService: dockerSvc, settingsService: settingsSvc, + activityService: activitySvc, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -150,6 +160,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Description: "Paginated list of containers", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersList), }, h.ListContainers) huma.Register(api, huma.Operation{ @@ -159,6 +170,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Container status counts", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersList), }, h.GetContainerStatusCounts) huma.Register(api, huma.Operation{ @@ -168,7 +180,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Create container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersCreate), }, h.CreateContainer) huma.Register(api, huma.Operation{ @@ -178,6 +190,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Get container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersRead), }, h.GetContainer) huma.Register(api, huma.Operation{ @@ -187,7 +200,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Start container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStart), }, h.StartContainer) huma.Register(api, huma.Operation{ @@ -197,7 +210,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Stop container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStop), }, h.StopContainer) huma.Register(api, huma.Operation{ @@ -207,7 +220,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Restart container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersRestart), }, h.RestartContainer) huma.Register(api, huma.Operation{ @@ -218,7 +231,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Description: "Pull latest image and recreate container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersRedeploy), }, h.RedeployContainer) huma.Register(api, huma.Operation{ @@ -228,7 +241,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Summary: "Delete container", Tags: []string{"Containers"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersDelete), }, h.DeleteContainer) huma.Register(api, huma.Operation{ @@ -239,7 +252,7 @@ func RegisterContainers(api huma.API, containerSvc *services.ContainerService, d Description: "Enable or disable auto-update for a specific container", Tags: []string{"Containers", "Updater"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersAutoUpdate), }, h.SetAutoUpdate) } @@ -262,7 +275,7 @@ func (h *ContainerHandler) ListContainers(ctx context.Context, input *ListContai Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -326,9 +339,9 @@ func (h *ContainerHandler) GetContainerStatusCounts(ctx context.Context, input * Body: ContainerStatusCountsResponse{ Success: true, Data: containertypes.StatusCounts{ - RunningContainers: int(running), - StoppedContainers: int(stopped), - TotalContainers: int(total), + RunningContainers: running, + StoppedContainers: stopped, + TotalContainers: total, }, }, }, nil @@ -536,9 +549,6 @@ func buildNetworkingConfig(body containertypes.Create) *network.NetworkingConfig } func (h *ContainerHandler) CreateContainer(ctx context.Context, input *CreateContainerInput) (*CreateContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -605,59 +615,64 @@ func (h *ContainerHandler) GetContainer(ctx context.Context, input *GetContainer } func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if h.containerService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized("not authenticated") - } - - if err := h.containerService.StartContainer(ctx, input.ContainerID, *user); err != nil { - return nil, huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) - } - - return &ContainerActionOutput{ - Body: ContainerActionResponse{ - Success: true, - Data: base.MessageResponse{Message: "Container started successfully"}, + return h.runContainerActionInternal(ctx, input, containerActionConfigInternal{ + ActivityType: models.ActivityTypeContainerStart, + Step: "Starting container", + StartMessage: "Container start requested", + CompleteMessage: "Container started", + SuccessMessage: "Container started successfully", + Action: func(runtimeCtx context.Context, containerID string, user models.User) error { + return h.containerService.StartContainer(runtimeCtx, containerID, user) }, - }, nil + Error: func(err error) error { + return huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error()) + }, + }) } func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if h.containerService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized("not authenticated") - } - - if err := h.containerService.StopContainer(ctx, input.ContainerID, *user); err != nil { - return nil, huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) - } - - return &ContainerActionOutput{ - Body: ContainerActionResponse{ - Success: true, - Data: base.MessageResponse{Message: "Container stopped successfully"}, + return h.runContainerActionInternal(ctx, input, containerActionConfigInternal{ + ActivityType: models.ActivityTypeContainerStop, + Step: "Stopping container", + StartMessage: "Container stop requested", + CompleteMessage: "Container stopped", + SuccessMessage: "Container stopped successfully", + Action: func(runtimeCtx context.Context, containerID string, user models.User) error { + return h.containerService.StopContainer(runtimeCtx, containerID, user) }, - }, nil + Error: func(err error) error { + return huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error()) + }, + }) } func (h *ContainerHandler) RestartContainer(ctx context.Context, input *ContainerActionInput) (*ContainerActionOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } + return h.runContainerActionInternal(ctx, input, containerActionConfigInternal{ + ActivityType: models.ActivityTypeContainerRestart, + Step: "Restarting container", + StartMessage: "Container restart requested", + CompleteMessage: "Container restarted", + SuccessMessage: "Container restarted successfully", + Action: func(runtimeCtx context.Context, containerID string, user models.User) error { + return h.containerService.RestartContainer(runtimeCtx, containerID, user) + }, + Error: func(err error) error { + return huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) + }, + }) +} + +type containerActionConfigInternal struct { + ActivityType models.ActivityType + Step string + StartMessage string + CompleteMessage string + SuccessMessage string + Action func(context.Context, string, models.User) error + Error func(error) error +} + +func (h *ContainerHandler) runContainerActionInternal(ctx context.Context, input *ContainerActionInput, cfg containerActionConfigInternal) (*ContainerActionOutput, error) { if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -667,22 +682,23 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe return nil, huma.Error401Unauthorized("not authenticated") } - if err := h.containerService.RestartContainer(ctx, input.ContainerID, *user); err != nil { - return nil, huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error()) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, cfg.ActivityType, "container", input.ContainerID, input.ContainerID, user, cfg.Step, cfg.StartMessage, models.JSON{"containerID": input.ContainerID}) + if err := cfg.Action(runtimeCtx, input.ContainerID, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.CompleteMessage, err) + return nil, cfg.Error(err) } + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.CompleteMessage, nil) return &ContainerActionOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container restarted successfully"}, + Data: base.MessageResponse{Message: cfg.SuccessMessage, ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *ContainerActionInput) (*GetContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -692,14 +708,24 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain return nil, huma.Error401Unauthorized("not authenticated") } - newContainerID, err := h.containerService.RedeployContainer(ctx, input.ContainerID, *user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Redeploying container") + redeployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) + newContainerID, err := h.containerService.RedeployContainer(redeployCtx, input.ContainerID, *user) if err != nil { + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container redeploy failed", err) return nil, huma.Error500InternalServerError((&common.ContainerRedeployError{Err: err}).Error()) } + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container redeployed", nil) // Fetch full container details to return (consistent with other endpoints) - details, inspectErr := h.containerService.GetContainerDetails(ctx, newContainerID) + details, inspectErr := h.containerService.GetContainerDetails(runtimeCtx, newContainerID) if inspectErr == nil { + details.ActivityID = utils.StringPtrFromTrimmed(activityID) + return &GetContainerOutput{ Body: ContainerDetailsResponse{ Success: true, @@ -714,16 +740,14 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain Body: ContainerDetailsResponse{ Success: true, Data: containertypes.Details{ - ID: newContainerID, + ID: newContainerID, + ActivityID: utils.StringPtrFromTrimmed(activityID), }, }, }, nil } func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteContainerInput) (*DeleteContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.containerService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -733,22 +757,23 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon return nil, huma.Error401Unauthorized("not authenticated") } - if err := h.containerService.DeleteContainer(ctx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes}) + if err := h.containerService.DeleteContainer(runtimeCtx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil { + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container deleted", err) return nil, huma.Error500InternalServerError((&common.ContainerDeleteError{Err: err}).Error()) } + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container deleted", nil) return &DeleteContainerOutput{ Body: ContainerActionResponse{ Success: true, - Data: base.MessageResponse{Message: "Container deleted successfully"}, + Data: base.MessageResponse{Message: "Container deleted successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } func (h *ContainerHandler) SetAutoUpdate(ctx context.Context, input *SetAutoUpdateInput) (*SetAutoUpdateOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.settingsService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/customize.go b/backend/api/handlers/customize.go index 67ae280e70..0a00fab582 100644 --- a/backend/api/handlers/customize.go +++ b/backend/api/handlers/customize.go @@ -9,6 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/category" "github.com/getarcaneapp/arcane/types/search" ) @@ -51,6 +52,7 @@ func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeS {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermCustomizeManage), }, h.Search) huma.Register(api, huma.Operation{ @@ -64,6 +66,7 @@ func RegisterCustomize(api huma.API, customizeSearchService *services.CustomizeS {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermCustomizeManage), }, h.GetCategories) } @@ -79,7 +82,8 @@ func (h *CustomizeHandler) Search(ctx context.Context, input *SearchCustomizeInp results := h.customizeSearchService.Search(input.Body.Query) - if !humamw.IsAdminFromContext(ctx) { + ps, _ := humamw.PermissionsFromContext(ctx) + if !ps.IsGlobalAdmin() { filtered := []category.Category{} for _, cat := range results.Results { if cat.ID != "registries" && cat.ID != "variables" { @@ -103,7 +107,8 @@ func (h *CustomizeHandler) GetCategories(ctx context.Context, input *GetCustomiz categories := h.customizeSearchService.GetCustomizeCategories() - if !humamw.IsAdminFromContext(ctx) { + ps, _ := humamw.PermissionsFromContext(ctx) + if !ps.IsGlobalAdmin() { filtered := []category.Category{} for _, cat := range categories { if cat.ID != "registries" && cat.ID != "variables" { diff --git a/backend/api/handlers/dashboard.go b/backend/api/handlers/dashboard.go index 742240bdab..4d95e2ce26 100644 --- a/backend/api/handlers/dashboard.go +++ b/backend/api/handlers/dashboard.go @@ -5,7 +5,9 @@ import ( "net/http" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" ) @@ -23,23 +25,6 @@ type GetDashboardOutput struct { Body base.ApiResponse[dashboardtypes.Snapshot] } -type GetDashboardActionItemsInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` - DebugAllGood bool `query:"debugAllGood" default:"false" doc:"Debug mode: force an empty action item list"` -} - -type GetDashboardActionItemsOutput struct { - Body base.ApiResponse[dashboardtypes.ActionItems] -} - -type GetDashboardEnvironmentsOverviewInput struct { - DebugAllGood bool `query:"debugAllGood" default:"false" doc:"Debug mode: force empty action item lists"` -} - -type GetDashboardEnvironmentsOverviewOutput struct { - Body base.ApiResponse[dashboardtypes.EnvironmentsOverview] -} - func RegisterDashboard(api huma.API, dashboardService *services.DashboardService) { h := &DashboardHandler{dashboardService: dashboardService} @@ -54,33 +39,8 @@ func RegisterDashboard(api huma.API, dashboardService *services.DashboardService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermDashboardRead), }, h.GetDashboard) - - huma.Register(api, huma.Operation{ - OperationID: "get-dashboard-action-items", - Method: http.MethodGet, - Path: "/environments/{id}/dashboard/action-items", - Summary: "Get dashboard action items", - Description: "Returns only dashboard action items that currently need attention", - Tags: []string{"Dashboard"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.GetActionItems) - - huma.Register(api, huma.Operation{ - OperationID: "get-dashboard-environments-overview", - Method: http.MethodGet, - Path: "/dashboard/environments", - Summary: "Get aggregate environments dashboard overview", - Description: "Returns dashboard summary data for all visible environments", - Tags: []string{"Dashboard"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.GetEnvironmentsOverview) } func (h *DashboardHandler) GetDashboard(ctx context.Context, input *GetDashboardInput) (*GetDashboardOutput, error) { @@ -109,59 +69,3 @@ func (h *DashboardHandler) GetDashboard(ctx context.Context, input *GetDashboard }, }, nil } - -func (h *DashboardHandler) GetActionItems(ctx context.Context, input *GetDashboardActionItemsInput) (*GetDashboardActionItemsOutput, error) { - if h.dashboardService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - // EnvironmentID is consumed by env proxy/auth middleware for routing/validation. - _ = input.EnvironmentID - - actionItems, err := h.dashboardService.GetActionItems(ctx, services.DashboardActionItemsOptions{ - DebugAllGood: input.DebugAllGood, - }) - if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) - } - - if actionItems == nil { - actionItems = &dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}} - } else if actionItems.Items == nil { - actionItems.Items = []dashboardtypes.ActionItem{} - } - - return &GetDashboardActionItemsOutput{ - Body: base.ApiResponse[dashboardtypes.ActionItems]{ - Success: true, - Data: *actionItems, - }, - }, nil -} - -func (h *DashboardHandler) GetEnvironmentsOverview( - ctx context.Context, - input *GetDashboardEnvironmentsOverviewInput, -) (*GetDashboardEnvironmentsOverviewOutput, error) { - if h.dashboardService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - overview, err := h.dashboardService.GetEnvironmentsOverview(ctx, services.DashboardActionItemsOptions{ - DebugAllGood: input.DebugAllGood, - }) - if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) - } - - if overview == nil { - return nil, huma.Error500InternalServerError("dashboard environments overview not available") - } - - return &GetDashboardEnvironmentsOverviewOutput{ - Body: base.ApiResponse[dashboardtypes.EnvironmentsOverview]{ - Success: true, - Data: *overview, - }, - }, nil -} diff --git a/backend/api/handlers/dashboard_test.go b/backend/api/handlers/dashboard_test.go index 70b90ac3c4..fd65304bb9 100644 --- a/backend/api/handlers/dashboard_test.go +++ b/backend/api/handlers/dashboard_test.go @@ -132,39 +132,3 @@ func TestDashboardHandlerGetDashboardReturnsSnapshot(t *testing.T) { {Kind: dashboardtypes.ActionItemKindExpiringKeys, Count: 1, Severity: dashboardtypes.ActionItemSeverityWarning}, }, snapshot.ActionItems.Items) } - -func TestDashboardHandlerGetEnvironmentsOverviewReturnsAggregateSummary(t *testing.T) { - db, settingsSvc := setupDashboardHandlerTestDB(t) - - require.NoError(t, db.WithContext(context.Background()).Create(&models.Environment{ - BaseModel: models.BaseModel{ID: "0", CreatedAt: time.Now()}, - Name: "Local Docker", - ApiUrl: "http://local.test", - Status: string(models.EnvironmentStatusOffline), - Enabled: true, - }).Error) - - handler := &DashboardHandler{ - dashboardService: services.NewDashboardService( - db, - nil, - nil, - nil, - nil, - settingsSvc, - nil, - services.NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil), - services.NewVersionService(nil, true, "1.2.3", "abcdef1234567890", nil, nil), - ), - } - - output, err := handler.GetEnvironmentsOverview(context.Background(), &GetDashboardEnvironmentsOverviewInput{}) - require.NoError(t, err) - require.NotNil(t, output) - require.True(t, output.Body.Success) - require.Equal(t, 1, output.Body.Data.Summary.TotalEnvironments) - require.Len(t, output.Body.Data.Environments, 1) - require.Equal(t, "0", output.Body.Data.Environments[0].Environment.ID) - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateSkipped, output.Body.Data.Environments[0].SnapshotState) - require.Nil(t, output.Body.Data.Environments[0].VersionInfo) -} diff --git a/backend/api/handlers/diagnostics.go b/backend/api/handlers/diagnostics.go new file mode 100644 index 0000000000..0b815eb51a --- /dev/null +++ b/backend/api/handlers/diagnostics.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/api/ws" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/logstream" + "github.com/getarcaneapp/arcane/types/system" +) + +// DiagnosticsHandler serves the REST diagnostics endpoints. The live WebSocket +// streams and pprof routes live in the api/ws package alongside the other +// streaming endpoints; the snapshot is assembled there too (ws.BuildDiagnostics). +type DiagnosticsHandler struct { + diag *services.DiagnosticsService +} + +type DiagnosticsInput struct{} + +type GetDiagnosticsOutput struct { + Body system.Diagnostics +} + +type GetDiagnosticsLogsOutput struct { + Body []system.LogEntry +} + +// RegisterDiagnostics registers the Huma diagnostics REST endpoints. +func RegisterDiagnostics(api huma.API, diag *services.DiagnosticsService) { + h := &DiagnosticsHandler{diag: diag} + + huma.Register(api, huma.Operation{ + OperationID: "get-diagnostics", + Method: http.MethodGet, + Path: "/diagnostics", + Summary: "Get runtime diagnostics", + Description: "Returns Go runtime, memory, garbage-collector, and WebSocket connection statistics.", + Tags: []string{"Diagnostics"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermDiagnosticsRead), + }, h.GetDiagnostics) + + huma.Register(api, huma.Operation{ + OperationID: "get-diagnostics-logs", + Method: http.MethodGet, + Path: "/diagnostics/logs", + Summary: "Get recent backend logs", + Description: "Returns the most recent buffered backend log entries (oldest first).", + Tags: []string{"Diagnostics"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermDiagnosticsRead), + }, h.GetRecentLogs) +} + +func (h *DiagnosticsHandler) GetDiagnostics(_ context.Context, _ *DiagnosticsInput) (*GetDiagnosticsOutput, error) { + return &GetDiagnosticsOutput{Body: ws.BuildDiagnostics(h.diag)}, nil +} + +func (h *DiagnosticsHandler) GetRecentLogs(_ context.Context, _ *DiagnosticsInput) (*GetDiagnosticsLogsOutput, error) { + return &GetDiagnosticsLogsOutput{Body: logstream.Default().Recent()}, nil +} diff --git a/backend/api/handlers/edge_mtls_ca.go b/backend/api/handlers/edge_mtls_ca.go index dbc595ce89..579ea151ab 100644 --- a/backend/api/handlers/edge_mtls_ca.go +++ b/backend/api/handlers/edge_mtls_ca.go @@ -3,6 +3,7 @@ package handlers import ( "crypto/x509" "encoding/pem" + "errors" "fmt" "math" "os" @@ -18,10 +19,10 @@ const edgeMTLSCertificateExpiryWarningWindow = 30 * 24 * time.Hour func generatedEdgeMTLSCAPathInternal(cfg *config.Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("config not available") + return "", errors.New("config not available") } if edge.NormalizeEdgeMTLSMode(cfg.EdgeMTLSMode) == edge.EdgeMTLSModeDisabled { - return "", fmt.Errorf("edge mTLS is disabled") + return "", errors.New("edge mTLS is disabled") } edgeCfg := &edge.Config{ @@ -48,10 +49,10 @@ func hasGeneratedEdgeMTLSCAInternal(cfg *config.Config) bool { func generatedEdgeMTLSClientCertPathInternal(cfg *config.Config, envID string) (string, error) { if cfg == nil { - return "", fmt.Errorf("config not available") + return "", errors.New("config not available") } if edge.NormalizeEdgeMTLSMode(cfg.EdgeMTLSMode) == edge.EdgeMTLSModeDisabled { - return "", fmt.Errorf("edge mTLS is disabled") + return "", errors.New("edge mTLS is disabled") } edgeCfg := &edge.Config{ @@ -82,7 +83,7 @@ func readGeneratedEdgeMTLSCertificateInfoInternal(cfg *config.Config, envID stri block, _ := pem.Decode(certPEM) if block == nil { - return nil, fmt.Errorf("decode generated edge mTLS client certificate PEM") + return nil, errors.New("decode generated edge mTLS client certificate PEM") } cert, err := x509.ParseCertificate(block.Bytes) @@ -93,11 +94,9 @@ func readGeneratedEdgeMTLSCertificateInfoInternal(cfg *config.Config, envID stri expiresAt := cert.NotAfter.UTC() now := time.Now().UTC() remaining := expiresAt.Sub(now) - daysRemaining := edgeMTLSCertificateDaysRemainingInternal(now, expiresAt) - info := &typesenvironment.EdgeMTLSCertificate{ ExpiresAt: &expiresAt, - DaysRemaining: &daysRemaining, + DaysRemaining: new(edgeMTLSCertificateDaysRemainingInternal(now, expiresAt)), Expired: now.After(expiresAt), ExpiringSoon: now.Before(expiresAt) && remaining <= edgeMTLSCertificateExpiryWarningWindow, } diff --git a/backend/api/handlers/environments.go b/backend/api/handlers/environments.go index af747fa6a0..dd1523b178 100644 --- a/backend/api/handlers/environments.go +++ b/backend/api/handlers/environments.go @@ -20,6 +20,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -71,7 +72,8 @@ type CreateEnvironmentInput struct { type EnvironmentWithApiKey struct { environment.Environment - ApiKey *string `json:"apiKey,omitempty" doc:"API key for pairing (only shown once during creation)"` //nolint:gosec // response schema requires apiKey naming + + ApiKey *string `json:"apiKey,omitempty" doc:"API key for pairing (only shown once during creation)"` } type CreateEnvironmentOutput struct { @@ -219,6 +221,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsList), }, h.ListEnvironments) huma.Register(api, huma.Operation{ @@ -232,7 +235,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsCreate), }, h.CreateEnvironment) huma.Register(api, huma.Operation{ @@ -246,6 +249,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.GetEnvironment) huma.Register(api, huma.Operation{ @@ -259,7 +263,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsUpdate), }, h.UpdateEnvironment) huma.Register(api, huma.Operation{ @@ -273,7 +277,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsDelete), }, h.DeleteEnvironment) huma.Register(api, huma.Operation{ @@ -287,7 +291,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.TestConnection) huma.Register(api, huma.Operation{ @@ -301,6 +305,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsSync), }, h.UpdateHeartbeat) huma.Register(api, huma.Operation{ @@ -314,7 +319,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsPair), }, h.PairAgent) huma.Register(api, huma.Operation{ @@ -328,7 +333,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsSync), }, h.SyncEnvironment) huma.Register(api, huma.Operation{ @@ -353,7 +358,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.GetDeploymentSnippets) huma.Register(api, huma.Operation{ @@ -367,7 +372,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.DownloadEnvironmentMTLSBundle) huma.Register(api, huma.Operation{ @@ -381,7 +386,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.DownloadEnvironmentMTLSFile) huma.Register(api, huma.Operation{ @@ -395,6 +400,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.GetEnvironmentVersion) huma.Register(api, huma.Operation{ @@ -408,7 +414,7 @@ func RegisterEnvironments(api huma.API, environmentService *services.Environment {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEnvironmentsRead), }, h.DownloadEdgeMTLSCA) } @@ -430,7 +436,7 @@ func (h *EnvironmentHandler) ListEnvironments(ctx context.Context, input *ListEn Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -445,7 +451,7 @@ func (h *EnvironmentHandler) ListEnvironments(ctx context.Context, input *ListEn return nil, huma.Error500InternalServerError((&common.EnvironmentListError{Err: err}).Error()) } for i := range envs { - h.applyEdgeRuntimeState(&envs[i]) + h.applyEdgeRuntimeStateInternal(&envs[i]) } return &ListEnvironmentsOutput{ @@ -469,10 +475,6 @@ func (h *EnvironmentHandler) CreateEnvironment(ctx context.Context, input *Creat return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) @@ -496,13 +498,13 @@ func (h *EnvironmentHandler) CreateEnvironment(ctx context.Context, input *Creat useApiKey := input.Body.UseApiKey != nil && *input.Body.UseApiKey if useApiKey { - return h.createEnvironmentWithApiKey(ctx, env, user) + return h.createEnvironmentWithApiKeyInternal(ctx, env, user) } - return h.createEnvironmentLegacy(ctx, env, user, input.Body) + return h.createEnvironmentLegacyInternal(ctx, env, user, input.Body) } -func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, env *models.Environment, user *models.User) (*CreateEnvironmentOutput, error) { +func (h *EnvironmentHandler) createEnvironmentWithApiKeyInternal(ctx context.Context, env *models.Environment, user *models.User) (*CreateEnvironmentOutput, error) { // New API key-based pairing flow env.Status = string(models.EnvironmentStatusPending) @@ -536,7 +538,7 @@ func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, en if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) return &CreateEnvironmentOutput{ Body: base.ApiResponse[EnvironmentWithApiKey]{ @@ -549,16 +551,8 @@ func (h *EnvironmentHandler) createEnvironmentWithApiKey(ctx context.Context, en }, nil } -func (h *EnvironmentHandler) createEnvironmentLegacy(ctx context.Context, env *models.Environment, user *models.User, body environment.Create) (*CreateEnvironmentOutput, error) { - // Legacy pairing flows - if (body.AccessToken == nil || *body.AccessToken == "") && body.BootstrapToken != nil && *body.BootstrapToken != "" { - token, err := h.environmentService.PairAgentWithBootstrap(ctx, body.ApiUrl, *body.BootstrapToken) - if err != nil { - slog.ErrorContext(ctx, "Failed to pair with agent", "apiUrl", body.ApiUrl, "error", err.Error()) - return nil, huma.Error502BadGateway((&common.AgentPairingError{Err: err}).Error()) - } - env.AccessToken = &token - } else if body.AccessToken != nil && *body.AccessToken != "" { +func (h *EnvironmentHandler) createEnvironmentLegacyInternal(ctx context.Context, env *models.Environment, user *models.User, body environment.Create) (*CreateEnvironmentOutput, error) { + if body.AccessToken != nil && *body.AccessToken != "" { env.AccessToken = body.AccessToken } @@ -569,14 +563,14 @@ func (h *EnvironmentHandler) createEnvironmentLegacy(ctx context.Context, env *m // Sync registries and git repositories in background (intentionally detached from request context) if created.AccessToken != nil && *created.AccessToken != "" { - h.triggerEnvironmentResourceSync(ctx, created.ID, created.Name, "environment creation") + h.triggerEnvironmentResourceSyncInternal(ctx, created.ID, created.Name, "environment creation") } out, mapErr := mapper.MapOne[*models.Environment, environment.Environment](created) if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) return &CreateEnvironmentOutput{ Body: base.ApiResponse[EnvironmentWithApiKey]{ @@ -603,7 +597,7 @@ func (h *EnvironmentHandler) GetEnvironment(ctx context.Context, input *GetEnvir if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) if env.IsEdge { if certInfo, certErr := readGeneratedEdgeMTLSCertificateInfoInternal(h.cfg, env.ID); certErr == nil { out.EdgeMTLSCertificate = certInfo @@ -624,17 +618,10 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - isLocalEnv := input.ID == localDockerEnvironmentID - updates := h.buildUpdateMap(&input.Body, isLocalEnv) + updates := h.buildUpdateMapInternal(&input.Body, isLocalEnv) - pairingSucceeded, err := h.handleEnvironmentPairing(ctx, input.ID, &input.Body, updates, isLocalEnv) - if err != nil { - return nil, err - } + h.handleEnvironmentPairingInternal(ctx, input.ID, &input.Body, updates, isLocalEnv) user, _ := humamw.GetCurrentUserFromContext(ctx) var userID, username *string @@ -647,13 +634,13 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat return nil, huma.Error500InternalServerError((&common.EnvironmentUpdateError{Err: updateErr}).Error()) } - h.triggerPostUpdateTasks(ctx, input.ID, updated, pairingSucceeded, &input.Body) //nolint:contextcheck // intentionally detached background tasks + h.triggerPostUpdateTasksInternal(ctx, input.ID, updated, &input.Body) out, mapErr := mapper.MapOne[*models.Environment, environment.Environment](updated) if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) // If regenerating API key, return the new key var newApiKey *string @@ -695,7 +682,7 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat if mapErr != nil { return nil, huma.Error500InternalServerError((&common.EnvironmentMappingError{Err: mapErr}).Error()) } - h.applyEdgeRuntimeState(&out) + h.applyEdgeRuntimeStateInternal(&out) newApiKey = new(apiKeyDto.Key) } @@ -711,7 +698,7 @@ func (h *EnvironmentHandler) UpdateEnvironment(ctx context.Context, input *Updat }, nil } -func (h *EnvironmentHandler) applyEdgeRuntimeState(env *environment.Environment) { +func (h *EnvironmentHandler) applyEdgeRuntimeStateInternal(env *environment.Environment) { services.ApplyEnvironmentRuntimeState(env) } @@ -721,10 +708,6 @@ func (h *EnvironmentHandler) DeleteEnvironment(ctx context.Context, input *Delet return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.ID == localDockerEnvironmentID { return nil, huma.Error400BadRequest((&common.LocalEnvironmentDeletionError{}).Error()) } @@ -755,10 +738,6 @@ func (h *EnvironmentHandler) TestConnection(ctx context.Context, input *TestConn return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - var apiUrl *string if input.Body != nil { apiUrl = input.Body.ApiUrl @@ -810,10 +789,6 @@ func (h *EnvironmentHandler) PairAgent(ctx context.Context, input *PairAgentInpu return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.ID != localDockerEnvironmentID { return nil, huma.Error404NotFound("Not found") } @@ -843,10 +818,6 @@ func (h *EnvironmentHandler) SyncEnvironment(ctx context.Context, input *SyncEnv return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - // Sync registries if err := h.environmentService.SyncRegistriesToEnvironment(ctx, input.ID); err != nil { slog.WarnContext(ctx, "Failed to sync registries", "environmentID", input.ID, "error", err.Error()) @@ -871,7 +842,7 @@ func (h *EnvironmentHandler) SyncEnvironment(ctx context.Context, input *SyncEnv // Helper Methods // ============================================================================ -func (h *EnvironmentHandler) buildUpdateMap(req *environment.Update, isLocalEnv bool) map[string]any { +func (h *EnvironmentHandler) buildUpdateMapInternal(req *environment.Update, isLocalEnv bool) map[string]any { updates := map[string]any{} if !isLocalEnv { @@ -890,36 +861,19 @@ func (h *EnvironmentHandler) buildUpdateMap(req *environment.Update, isLocalEnv return updates } -func (h *EnvironmentHandler) handleEnvironmentPairing(ctx context.Context, environmentID string, req *environment.Update, updates map[string]any, isLocalEnv bool) (bool, error) { - pairingSucceeded := false - +func (h *EnvironmentHandler) handleEnvironmentPairingInternal(ctx context.Context, environmentID string, req *environment.Update, updates map[string]any, isLocalEnv bool) { + _ = ctx + _ = environmentID if isLocalEnv { - return pairingSucceeded, nil + return } - if req.AccessToken == nil && req.BootstrapToken != nil && *req.BootstrapToken != "" { - current, err := h.environmentService.GetEnvironmentByID(ctx, environmentID) - if err != nil || current == nil { - return false, huma.Error404NotFound("Environment not found") - } - - apiUrl := current.ApiUrl - if req.ApiUrl != nil && *req.ApiUrl != "" { - apiUrl = *req.ApiUrl - } - - if _, err := h.environmentService.PairAndPersistAgentToken(ctx, environmentID, apiUrl, *req.BootstrapToken); err != nil { - return false, huma.Error502BadGateway("Agent pairing failed: " + err.Error()) - } - pairingSucceeded = true - } else if req.AccessToken != nil { + if req.AccessToken != nil { updates["access_token"] = *req.AccessToken } - - return pairingSucceeded, nil } -func (h *EnvironmentHandler) triggerPostUpdateTasks(ctx context.Context, environmentID string, updated *models.Environment, pairingSucceeded bool, req *environment.Update) { //nolint:contextcheck // intentionally spawns background tasks +func (h *EnvironmentHandler) triggerPostUpdateTasksInternal(ctx context.Context, environmentID string, updated *models.Environment, req *environment.Update) { if updated.Enabled { detachedCtx := context.WithoutCancel(ctx) go func(syncCtx context.Context, envID string, envName string) { @@ -931,12 +885,12 @@ func (h *EnvironmentHandler) triggerPostUpdateTasks(ctx context.Context, environ }(detachedCtx, environmentID, updated.Name) } - if updated.AccessToken != nil && *updated.AccessToken != "" && (pairingSucceeded || (req.AccessToken != nil && *req.AccessToken != "") || req.Name != nil) { - h.triggerEnvironmentResourceSync(ctx, environmentID, updated.Name, "environment update") + if updated.AccessToken != nil && *updated.AccessToken != "" && ((req.AccessToken != nil && *req.AccessToken != "") || req.Name != nil) { + h.triggerEnvironmentResourceSyncInternal(ctx, environmentID, updated.Name, "environment update") } } -func (h *EnvironmentHandler) triggerEnvironmentResourceSync(ctx context.Context, environmentID string, environmentName string, reason string) { //nolint:contextcheck // intentionally spawns background tasks +func (h *EnvironmentHandler) triggerEnvironmentResourceSyncInternal(ctx context.Context, environmentID string, environmentName string, reason string) { detachedCtx := context.WithoutCancel(ctx) go func(syncCtx context.Context, envID string, envName string, syncReason string) { @@ -1000,7 +954,7 @@ func (h *EnvironmentHandler) PairEnvironment(ctx context.Context, input *PairEnv } slog.InfoContext(ctx, "Environment pairing completed", "environmentID", *envID, "environmentName", env.Name) - h.triggerEnvironmentResourceSync(ctx, *envID, env.Name, "environment pairing") + h.triggerEnvironmentResourceSyncInternal(ctx, *envID, env.Name, "environment pairing") return &PairEnvironmentOutput{ Body: base.ApiResponse[base.MessageResponse]{ @@ -1018,10 +972,6 @@ func (h *EnvironmentHandler) GetDeploymentSnippets(ctx context.Context, input *G return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - env, err := h.environmentService.GetEnvironmentByID(ctx, input.ID) if err != nil { return nil, huma.Error404NotFound("Environment not found") @@ -1142,7 +1092,7 @@ func (h *EnvironmentHandler) GetEnvironmentVersion(ctx context.Context, input *G } client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) //nolint:gosec // intentional request to configured remote environment API URL + resp, err := client.Do(req) if err != nil { return nil, huma.Error500InternalServerError("Request failed: " + err.Error()) } @@ -1173,10 +1123,6 @@ func (h *EnvironmentHandler) GetEnvironmentVersion(ctx context.Context, input *G // DownloadEdgeMTLSCA downloads the Arcane-managed edge mTLS CA certificate. func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *DownloadEdgeMTLSCAInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - caPath, err := generatedEdgeMTLSCAPathInternal(h.cfg) if err != nil { return nil, huma.Error404NotFound("Arcane-managed edge mTLS CA is not available") @@ -1203,7 +1149,7 @@ func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *Download slog.WarnContext(humaCtx.Context(), "Failed to stream edge mTLS CA download", "fileName", fileName, "bytesWritten", written, "bytesExpected", len(caPEM), "error", writeErr) return } - h.logMTLSAuditEvent(humaCtx.Context(), nil, models.EventTypeEnvironmentMTLSDownload, + h.logMTLSAuditEventInternal(humaCtx.Context(), nil, models.EventTypeEnvironmentMTLSDownload, "mTLS CA downloaded", fmt.Sprintf("Administrator downloaded edge mTLS CA %q", fileName), models.JSON{ @@ -1215,11 +1161,7 @@ func (h *EnvironmentHandler) DownloadEdgeMTLSCA(ctx context.Context, _ *Download } func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, input *DownloadEnvironmentMTLSBundleInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - env, files, err := h.loadEnvironmentMTLSFiles(ctx, input.ID) + env, files, err := h.loadEnvironmentMTLSFilesInternal(ctx, input.ID) if err != nil { return nil, err } @@ -1264,7 +1206,7 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, slog.WarnContext(humaCtx.Context(), "Failed to stream edge mTLS bundle download", "environmentID", input.ID, "fileName", fileName, "bytesWritten", written, "bytesExpected", archive.Len(), "error", writeErr) return } - h.logMTLSAuditEvent(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, + h.logMTLSAuditEventInternal(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, "mTLS bundle downloaded", fmt.Sprintf("Administrator downloaded edge mTLS bundle %q (%d files)", fileName, len(files)), models.JSON{ @@ -1277,11 +1219,7 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSBundle(ctx context.Context, } func (h *EnvironmentHandler) DownloadEnvironmentMTLSFile(ctx context.Context, input *DownloadEnvironmentMTLSFileInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - env, file, err := h.loadEnvironmentMTLSFile(ctx, input.ID, input.FileName) + env, file, err := h.loadEnvironmentMTLSFileInternal(ctx, input.ID, input.FileName) if err != nil { return nil, err } @@ -1299,7 +1237,7 @@ func (h *EnvironmentHandler) DownloadEnvironmentMTLSFile(ctx context.Context, in slog.WarnContext(humaCtx.Context(), "Failed to stream edge mTLS asset download", "environmentID", input.ID, "fileName", file.Name, "bytesWritten", written, "bytesExpected", len(fileContent), "error", writeErr) return } - h.logMTLSAuditEvent(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, + h.logMTLSAuditEventInternal(humaCtx.Context(), env, models.EventTypeEnvironmentMTLSDownload, "mTLS asset downloaded", fmt.Sprintf("Administrator downloaded edge mTLS asset %q", file.Name), models.JSON{ @@ -1336,7 +1274,7 @@ func (h *EnvironmentHandler) loadEnvironmentMTLSEnvironmentInternal(ctx context. return env, nil } -func (h *EnvironmentHandler) loadEnvironmentMTLSFiles(ctx context.Context, environmentID string) (*models.Environment, []services.DeploymentSnippetFile, error) { +func (h *EnvironmentHandler) loadEnvironmentMTLSFilesInternal(ctx context.Context, environmentID string) (*models.Environment, []services.DeploymentSnippetFile, error) { env, err := h.loadEnvironmentMTLSEnvironmentInternal(ctx, environmentID) if err != nil { return nil, nil, err @@ -1360,8 +1298,8 @@ func (h *EnvironmentHandler) loadEnvironmentMTLSFiles(ctx context.Context, envir return env, snippets.MTLS.Files, nil } -func (h *EnvironmentHandler) loadEnvironmentMTLSFile(ctx context.Context, environmentID string, fileName string) (*models.Environment, services.DeploymentSnippetFile, error) { - env, files, err := h.loadEnvironmentMTLSFiles(ctx, environmentID) +func (h *EnvironmentHandler) loadEnvironmentMTLSFileInternal(ctx context.Context, environmentID string, fileName string) (*models.Environment, services.DeploymentSnippetFile, error) { + env, files, err := h.loadEnvironmentMTLSFilesInternal(ctx, environmentID) if err != nil { return nil, services.DeploymentSnippetFile{}, err } @@ -1434,10 +1372,10 @@ func environmentMTLSAssetFileModeInternal(file services.DeploymentSnippetFile) o return 0o644 } -// logMTLSAuditEvent records an audit event for administrator-triggered +// logMTLSAuditEventInternal records an audit event for administrator-triggered // edge mTLS actions (downloads, bundle exports). Must never include raw // certificate content or private key material. -func (h *EnvironmentHandler) logMTLSAuditEvent(ctx context.Context, env *models.Environment, eventType models.EventType, title, description string, extra models.JSON) { +func (h *EnvironmentHandler) logMTLSAuditEventInternal(ctx context.Context, env *models.Environment, eventType models.EventType, title, description string, extra models.JSON) { if h == nil || h.eventService == nil { return } @@ -1445,10 +1383,8 @@ func (h *EnvironmentHandler) logMTLSAuditEvent(ctx context.Context, env *models. user, _ := humamw.GetCurrentUserFromContext(ctx) var userID, username *string if user != nil { - uid := user.ID - uname := user.Username - userID = &uid - username = &uname + userID = new(user.ID) + username = new(user.Username) } if extra == nil { @@ -1469,11 +1405,9 @@ func (h *EnvironmentHandler) logMTLSAuditEvent(ctx context.Context, env *models. } if env != nil { envID := env.ID - envName := env.Name - resourceType := "environment" - req.ResourceType = &resourceType + req.ResourceType = new("environment") req.ResourceID = &envID - req.ResourceName = &envName + req.ResourceName = new(env.Name) req.EnvironmentID = &envID } diff --git a/backend/api/handlers/environments_test.go b/backend/api/handlers/environments_test.go index c05daef361..b2dcd19e84 100644 --- a/backend/api/handlers/environments_test.go +++ b/backend/api/handlers/environments_test.go @@ -43,7 +43,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: false, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusOnline), env.Status) assert.Nil(t, env.EdgeTransport) @@ -68,7 +68,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusOffline), env.Status) assert.Nil(t, env.EdgeTransport) @@ -95,7 +95,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusPending), env.Status) assert.Nil(t, env.EdgeTransport) @@ -129,7 +129,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusOnline), env.Status) if assert.NotNil(t, env.EdgeTransport) { @@ -166,7 +166,7 @@ func TestEnvironmentHandlerApplyEdgeRuntimeState(t *testing.T) { IsEdge: true, } - handler.applyEdgeRuntimeState(&env) + handler.applyEdgeRuntimeStateInternal(&env) assert.Equal(t, string(models.EnvironmentStatusStandby), env.Status) if assert.NotNil(t, env.Connected) { diff --git a/backend/api/handlers/events.go b/backend/api/handlers/events.go index ae15b22eed..4d948a090c 100644 --- a/backend/api/handlers/events.go +++ b/backend/api/handlers/events.go @@ -7,6 +7,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/event" ) @@ -96,7 +97,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.ListEvents) huma.Register(api, huma.Operation{ @@ -110,7 +111,10 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + // TODO: introduce a dedicated PermEventsCreate (and PermEventsDelete) + // permission. Today the events taxonomy only exposes PermEventsRead, + // so admin-level write actions reuse the read permission. + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.CreateEvent) huma.Register(api, huma.Operation{ @@ -124,7 +128,10 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + // TODO: introduce a dedicated PermEventsDelete permission. Today the + // events taxonomy only exposes PermEventsRead, so admin-level write + // actions reuse the read permission. + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.DeleteEvent) huma.Register(api, huma.Operation{ @@ -138,7 +145,7 @@ func RegisterEvents(api huma.API, eventService *services.EventService, apiKeySvc {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermEventsRead), }, h.GetEventsByEnvironment) } @@ -152,11 +159,7 @@ func (h *EventHandler) ListEvents(ctx context.Context, input *ListEventsInput) ( return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Severity != "" { params.Filters["severity"] = input.Severity @@ -191,15 +194,11 @@ func (h *EventHandler) GetEventsByEnvironment(ctx context.Context, input *GetEve return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.EnvironmentID == "" { return nil, huma.Error400BadRequest((&common.EnvironmentIDRequiredError{}).Error()) } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Severity != "" { params.Filters["severity"] = input.Severity @@ -244,10 +243,6 @@ func (h *EventHandler) CreateEvent(ctx context.Context, input *CreateEventInput) } } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - evt, err := h.eventService.CreateEventFromDto(ctx, input.Body) if err != nil { return nil, huma.Error500InternalServerError((&common.EventCreationError{Err: err}).Error()) @@ -267,10 +262,6 @@ func (h *EventHandler) DeleteEvent(ctx context.Context, input *DeleteEventInput) return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if input.EventID == "" { return nil, huma.Error400BadRequest((&common.EventIDRequiredError{}).Error()) } diff --git a/backend/api/handlers/federated.go b/backend/api/handlers/federated.go new file mode 100644 index 0000000000..b33c207296 --- /dev/null +++ b/backend/api/handlers/federated.go @@ -0,0 +1,335 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/types/base" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" + "github.com/labstack/echo/v4" +) + +// FederatedCredentialHandler provides Huma-based federated credential +// management endpoints. +type FederatedCredentialHandler struct { + federatedCredentialService *services.FederatedCredentialService +} + +type federatedTokenExchangeError struct { + Error string `json:"error"` //nolint:tagliatelle // RFC 6749 wire shape is snake_case. + ErrorDescription string `json:"error_description,omitempty"` //nolint:tagliatelle // RFC 6749 wire shape is snake_case. +} + +type ListFederatedCredentialsInput struct { + Search string `query:"search" doc:"Search query for filtering by name, issuer, or subject"` + Sort string `query:"sort" doc:"Column to sort by"` + Order string `query:"order" default:"asc" doc:"Sort direction (asc or desc)"` + Start int `query:"start" default:"0" doc:"Start index for pagination"` + Limit int `query:"limit" default:"20" doc:"Number of items per page"` +} + +type ListFederatedCredentialsOutput struct { + Body base.Paginated[federatedtypes.FederatedCredential] +} + +type CreateFederatedCredentialInput struct { + Body federatedtypes.CreateFederatedCredential +} + +type CreateFederatedCredentialOutput struct { + Body base.ApiResponse[federatedtypes.FederatedCredential] +} + +type GetFederatedCredentialInput struct { + ID string `path:"id" doc:"Federated credential ID"` +} + +type GetFederatedCredentialOutput struct { + Body base.ApiResponse[federatedtypes.FederatedCredential] +} + +type UpdateFederatedCredentialInput struct { + ID string `path:"id" doc:"Federated credential ID"` + Body federatedtypes.UpdateFederatedCredential +} + +type UpdateFederatedCredentialOutput struct { + Body base.ApiResponse[federatedtypes.FederatedCredential] +} + +type DeleteFederatedCredentialInput struct { + ID string `path:"id" doc:"Federated credential ID"` +} + +type DeleteFederatedCredentialOutput struct { + Body base.ApiResponse[base.MessageResponse] +} + +// RegisterFederatedTokenExchange registers the public RFC 8693 token exchange +// endpoint. It intentionally uses Echo because the standard requires form +// encoding while the rest of Arcane's Huma API is JSON-first. +func RegisterFederatedTokenExchange(g *echo.Group, federatedCredentialService *services.FederatedCredentialService) { + g.POST("/auth/federated/token", func(c echo.Context) error { + if federatedCredentialService == nil { + return c.JSON(http.StatusInternalServerError, federatedTokenExchangeError{ + Error: "server_error", + ErrorDescription: "service not available", + }) + } + if err := c.Request().ParseForm(); err != nil { + return c.JSON(http.StatusBadRequest, federatedTokenExchangeError{ + Error: "invalid_request", + ErrorDescription: "invalid token exchange request", + }) + } + + form := c.Request().Form + resp, err := federatedCredentialService.ExchangeToken(c.Request().Context(), federatedtypes.TokenExchangeRequest{ + GrantType: form.Get("grant_type"), + SubjectToken: form.Get("subject_token"), + SubjectTokenType: form.Get("subject_token_type"), + Audience: form.Get("audience"), + Scope: form.Get("scope"), + RequestedTokenType: form.Get("requested_token_type"), + }) + if err != nil { + return writeFederatedTokenExchangeErrorInternal(c, err) + } + return c.JSON(http.StatusOK, resp) + }) +} + +// RegisterFederatedCredentials registers federated credential management routes. +func RegisterFederatedCredentials(api huma.API, federatedCredentialService *services.FederatedCredentialService) { + h := &FederatedCredentialHandler{ + federatedCredentialService: federatedCredentialService, + } + + huma.Register(api, huma.Operation{ + OperationID: "list-federated-credentials", + Method: http.MethodGet, + Path: "/federated-credentials", + Summary: "List federated credentials", + Description: "Get a paginated list of workload identity federation trust rules", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermFederatedList), + }, h.ListFederatedCredentials) + + huma.Register(api, huma.Operation{ + OperationID: "create-federated-credential", + Method: http.MethodPost, + Path: "/federated-credentials", + Summary: "Create a federated credential", + Description: "Create a workload identity federation trust rule", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.CreateFederatedCredential) + + huma.Register(api, huma.Operation{ + OperationID: "get-federated-credential", + Method: http.MethodGet, + Path: "/federated-credentials/{id}", + Summary: "Get a federated credential", + Description: "Get details of a workload identity federation trust rule", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequirePermission(api, authz.PermFederatedRead), + }, h.GetFederatedCredential) + + huma.Register(api, huma.Operation{ + OperationID: "update-federated-credential", + Method: http.MethodPut, + Path: "/federated-credentials/{id}", + Summary: "Update a federated credential", + Description: "Update a workload identity federation trust rule", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.UpdateFederatedCredential) + + huma.Register(api, huma.Operation{ + OperationID: "delete-federated-credential", + Method: http.MethodDelete, + Path: "/federated-credentials/{id}", + Summary: "Delete a federated credential", + Description: "Delete a workload identity federation trust rule and its service user", + Tags: []string{"Federated Credentials"}, + Security: []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + }, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.DeleteFederatedCredential) +} + +func (h *FederatedCredentialHandler) ListFederatedCredentials(ctx context.Context, input *ListFederatedCredentialsInput) (*ListFederatedCredentialsOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + + credentials, paginationResp, err := h.federatedCredentialService.List(ctx, pagination.QueryParams{ + SearchQuery: pagination.SearchQuery{Search: input.Search}, + SortParams: pagination.SortParams{ + Sort: input.Sort, + Order: pagination.SortOrder(input.Order), + }, + Params: pagination.Params{ + Start: input.Start, + Limit: input.Limit, + }, + }) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list federated credentials") + } + + return &ListFederatedCredentialsOutput{ + Body: base.Paginated[federatedtypes.FederatedCredential]{ + Success: true, + Data: credentials, + Pagination: base.PaginationResponse{ + TotalPages: paginationResp.TotalPages, + TotalItems: paginationResp.TotalItems, + CurrentPage: paginationResp.CurrentPage, + ItemsPerPage: paginationResp.ItemsPerPage, + GrandTotalItems: paginationResp.GrandTotalItems, + }, + }, + }, nil +} + +func (h *FederatedCredentialHandler) CreateFederatedCredential(ctx context.Context, input *CreateFederatedCredentialInput) (*CreateFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + credential, err := h.federatedCredentialService.Create(ctx, user.ID, input.Body) + if err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &CreateFederatedCredentialOutput{ + Body: base.ApiResponse[federatedtypes.FederatedCredential]{ + Success: true, + Data: *credential, + }, + }, nil +} + +func (h *FederatedCredentialHandler) GetFederatedCredential(ctx context.Context, input *GetFederatedCredentialInput) (*GetFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + credential, err := h.federatedCredentialService.Get(ctx, input.ID) + if err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &GetFederatedCredentialOutput{ + Body: base.ApiResponse[federatedtypes.FederatedCredential]{ + Success: true, + Data: *credential, + }, + }, nil +} + +func (h *FederatedCredentialHandler) UpdateFederatedCredential(ctx context.Context, input *UpdateFederatedCredentialInput) (*UpdateFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + user, exists := humamw.GetCurrentUserFromContext(ctx) + if !exists { + return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + } + + credential, err := h.federatedCredentialService.Update(ctx, user.ID, input.ID, input.Body) + if err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &UpdateFederatedCredentialOutput{ + Body: base.ApiResponse[federatedtypes.FederatedCredential]{ + Success: true, + Data: *credential, + }, + }, nil +} + +func (h *FederatedCredentialHandler) DeleteFederatedCredential(ctx context.Context, input *DeleteFederatedCredentialInput) (*DeleteFederatedCredentialOutput, error) { + if h.federatedCredentialService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if err := h.federatedCredentialService.Delete(ctx, input.ID); err != nil { + return nil, federatedCredentialManagementErrorInternal(err) + } + + return &DeleteFederatedCredentialOutput{ + Body: base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: base.MessageResponse{ + Message: "Federated credential deleted successfully", + }, + }, + }, nil +} + +func writeFederatedTokenExchangeErrorInternal(c echo.Context, err error) error { + var code string + description := "token exchange rejected" + + switch { + case common.IsErrorFederatedCredentialInvalidRequest(err): + code = "invalid_request" + description = "invalid token exchange request" + case common.IsErrorFederatedCredentialInvalidGrant(err), + common.IsErrorFederatedCredentialNotFound(err): + code = "invalid_grant" + case common.IsErrorFederatedCredentialInvalid(err): + code = "invalid_request" + default: + code = "server_error" + description = "token exchange failed" + } + + return c.JSON(http.StatusBadRequest, federatedTokenExchangeError{ + Error: code, + ErrorDescription: description, + }) +} + +func federatedCredentialManagementErrorInternal(err error) error { + switch { + case common.IsErrorFederatedCredentialNotFound(err): + return huma.Error404NotFound("federated credential not found") + case common.IsErrorFederatedCredentialInvalid(err), + common.IsErrorFederatedCredentialInvalidRequest(err): + return huma.Error400BadRequest("invalid federated credential") + case common.IsErrorFederatedCredentialPermissionEscalation(err): + return huma.Error403Forbidden("permission denied") + default: + return huma.Error500InternalServerError("federated credential operation failed") + } +} diff --git a/backend/api/handlers/fonts.go b/backend/api/handlers/fonts.go deleted file mode 100644 index 137ae75004..0000000000 --- a/backend/api/handlers/fonts.go +++ /dev/null @@ -1,88 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - - "github.com/danielgtaylor/huma/v2" - "github.com/getarcaneapp/arcane/backend/internal/services" -) - -// FontsHandler provides Huma-based font endpoints. -type FontsHandler struct { - fontService *services.FontService -} - -// --- Huma Input/Output Wrappers --- - -type GetFontOutput struct { - ContentType string `header:"Content-Type"` - CacheControl string `header:"Cache-Control"` - Body []byte -} - -// RegisterFonts registers font routes using Huma. -func RegisterFonts(api huma.API, fontService *services.FontService) { - h := &FontsHandler{ - fontService: fontService, - } - - huma.Register(api, huma.Operation{ - OperationID: "get-sans-font", - Method: http.MethodGet, - Path: "/fonts/sans", - Summary: "Get sans-serif font", - Description: "Get the application sans-serif font (Mona Sans)", - Tags: []string{"Fonts"}, - Security: []map[string][]string{}, // Public endpoint - }, h.GetSansFont) - - huma.Register(api, huma.Operation{ - OperationID: "get-mono-font", - Method: http.MethodGet, - Path: "/fonts/mono", - Summary: "Get monospace font", - Description: "Get the application monospace font (Mona Sans Mono)", - Tags: []string{"Fonts"}, - Security: []map[string][]string{}, // Public endpoint - }, h.GetMonoFont) -} - -// GetSansFont returns the sans-serif font. -func (h *FontsHandler) GetSansFont(ctx context.Context, input *struct{}) (*GetFontOutput, error) { - if h.fontService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - data, mimeType, err := h.fontService.GetSansFont() - if err != nil { - return nil, huma.Error404NotFound("font not found") - } - - return h.createFontResponse(data, mimeType), nil -} - -// GetMonoFont returns the monospace font. -func (h *FontsHandler) GetMonoFont(ctx context.Context, input *struct{}) (*GetFontOutput, error) { - if h.fontService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - data, mimeType, err := h.fontService.GetMonoFont() - if err != nil { - return nil, huma.Error404NotFound("font not found") - } - - return h.createFontResponse(data, mimeType), nil -} - -func (h *FontsHandler) createFontResponse(data []byte, mimeType string) *GetFontOutput { - // Cache for 1 year, immutable - cacheControl := "public, max-age=31536000, immutable" - - return &GetFontOutput{ - ContentType: mimeType, - CacheControl: cacheControl, - Body: data, - } -} diff --git a/backend/api/handlers/git_repositories.go b/backend/api/handlers/git_repositories.go index 8de9561fb1..c77860f27f 100644 --- a/backend/api/handlers/git_repositories.go +++ b/backend/api/handlers/git_repositories.go @@ -4,11 +4,10 @@ import ( "context" "github.com/danielgtaylor/huma/v2" - humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/gitops" ) @@ -117,127 +116,15 @@ type SyncGitRepositoriesOutput struct { func RegisterGitRepositories(api huma.API, repoService *services.GitRepositoryService) { h := &GitRepositoryHandler{repoService: repoService} - huma.Register(api, huma.Operation{ - OperationID: "listGitRepositories", - Method: "GET", - Path: "/customize/git-repositories", - Summary: "List git repositories", - Description: "Get a paginated list of git repositories", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.ListRepositories) - - huma.Register(api, huma.Operation{ - OperationID: "createGitRepository", - Method: "POST", - Path: "/customize/git-repositories", - Summary: "Create a git repository", - Description: "Create a new git repository configuration", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.CreateRepository) - - huma.Register(api, huma.Operation{ - OperationID: "getGitRepository", - Method: "GET", - Path: "/customize/git-repositories/{id}", - Summary: "Get a git repository", - Description: "Get a git repository by ID", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.GetRepository) - - huma.Register(api, huma.Operation{ - OperationID: "updateGitRepository", - Method: "PUT", - Path: "/customize/git-repositories/{id}", - Summary: "Update a git repository", - Description: "Update an existing git repository configuration", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.UpdateRepository) - - huma.Register(api, huma.Operation{ - OperationID: "deleteGitRepository", - Method: "DELETE", - Path: "/customize/git-repositories/{id}", - Summary: "Delete a git repository", - Description: "Delete a git repository configuration by ID", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.DeleteRepository) - - huma.Register(api, huma.Operation{ - OperationID: "testGitRepository", - Method: "POST", - Path: "/customize/git-repositories/{id}/test", - Summary: "Test a git repository", - Description: "Test connectivity and authentication to a git repository", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.TestRepository) - - huma.Register(api, huma.Operation{ - OperationID: "listGitRepositoryBranches", - Method: "GET", - Path: "/customize/git-repositories/{id}/branches", - Summary: "List repository branches", - Description: "Get all branches from a git repository with default branch detection", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.ListBranches) - - huma.Register(api, huma.Operation{ - OperationID: "browseGitRepositoryFiles", - Method: "GET", - Path: "/customize/git-repositories/{id}/files", - Summary: "Browse repository files", - Description: "Browse files and directories in a git repository", - Tags: []string{"Customize"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.BrowseFiles) - - huma.Register(api, huma.Operation{ - OperationID: "syncGitRepositories", - Method: "POST", - Path: "/git-repositories/sync", - Summary: "Sync git repositories", - Description: "Sync git repositories from a manager to this agent instance", - Tags: []string{"Git Repositories"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.SyncRepositories) + registerCustomizeSecuredInternal(api, "listGitRepositories", "GET", "/customize/git-repositories", "List git repositories", "Get a paginated list of git repositories", authz.PermGitReposList, h.ListRepositories) + registerCustomizeSecuredInternal(api, "createGitRepository", "POST", "/customize/git-repositories", "Create a git repository", "Create a new git repository configuration", authz.PermGitReposCreate, h.CreateRepository) + registerCustomizeSecuredInternal(api, "getGitRepository", "GET", "/customize/git-repositories/{id}", "Get a git repository", "Get a git repository by ID", authz.PermGitReposRead, h.GetRepository) + registerCustomizeSecuredInternal(api, "updateGitRepository", "PUT", "/customize/git-repositories/{id}", "Update a git repository", "Update an existing git repository configuration", authz.PermGitReposUpdate, h.UpdateRepository) + registerCustomizeSecuredInternal(api, "deleteGitRepository", "DELETE", "/customize/git-repositories/{id}", "Delete a git repository", "Delete a git repository configuration by ID", authz.PermGitReposDelete, h.DeleteRepository) + registerCustomizeSecuredInternal(api, "testGitRepository", "POST", "/customize/git-repositories/{id}/test", "Test a git repository", "Test connectivity and authentication to a git repository", authz.PermGitReposTest, h.TestRepository) + registerCustomizeSecuredInternal(api, "listGitRepositoryBranches", "GET", "/customize/git-repositories/{id}/branches", "List repository branches", "Get all branches from a git repository with default branch detection", authz.PermGitReposRead, h.ListBranches) + registerCustomizeSecuredInternal(api, "browseGitRepositoryFiles", "GET", "/customize/git-repositories/{id}/files", "Browse repository files", "Browse files and directories in a git repository", authz.PermGitReposRead, h.BrowseFiles) + registerTaggedSecuredInternal(api, "syncGitRepositories", "POST", "/git-repositories/sync", "Sync git repositories", "Sync git repositories from a manager to this agent instance", "Git Repositories", authz.PermGitReposSync, h.SyncRepositories) } // ============================================================================ @@ -250,7 +137,7 @@ func (h *GitRepositoryHandler) ListRepositories(ctx context.Context, input *List return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) repositories, paginationResp, err := h.repoService.GetRepositoriesPaginated(ctx, params) if err != nil { @@ -278,14 +165,7 @@ func (h *GitRepositoryHandler) CreateRepository(ctx context.Context, input *Crea return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) repo, err := h.repoService.CreateRepository(ctx, input.Body, actor) if err != nil { @@ -293,16 +173,15 @@ func (h *GitRepositoryHandler) CreateRepository(ctx context.Context, input *Crea return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositoryCreationError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitRepository, gitops.GitRepository](repo) + body, mapErr := mapOneAPIResponseInternal[*models.GitRepository, gitops.GitRepository](repo, func(err error) string { + return (&common.GitRepositoryMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitRepositoryMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &CreateGitRepositoryOutput{ - Body: base.ApiResponse[gitops.GitRepository]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -318,16 +197,15 @@ func (h *GitRepositoryHandler) GetRepository(ctx context.Context, input *GetGitR return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositoryRetrievalError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitRepository, gitops.GitRepository](repo) + body, mapErr := mapOneAPIResponseInternal[*models.GitRepository, gitops.GitRepository](repo, func(err error) string { + return (&common.GitRepositoryMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitRepositoryMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &GetGitRepositoryOutput{ - Body: base.ApiResponse[gitops.GitRepository]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -337,14 +215,7 @@ func (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *Upda return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) repo, err := h.repoService.UpdateRepository(ctx, input.ID, input.Body, actor) if err != nil { @@ -352,16 +223,15 @@ func (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *Upda return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositoryUpdateError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitRepository, gitops.GitRepository](repo) + body, mapErr := mapOneAPIResponseInternal[*models.GitRepository, gitops.GitRepository](repo, func(err error) string { + return (&common.GitRepositoryMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitRepositoryMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &UpdateGitRepositoryOutput{ - Body: base.ApiResponse[gitops.GitRepository]{ - Success: true, - Data: out, - }, + Body: body, }, nil } @@ -371,14 +241,7 @@ func (h *GitRepositoryHandler) DeleteRepository(ctx context.Context, input *Dele return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) if err := h.repoService.DeleteRepository(ctx, input.ID, actor); err != nil { apiErr := models.ToAPIError(err) @@ -401,14 +264,7 @@ func (h *GitRepositoryHandler) TestRepository(ctx context.Context, input *TestGi return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) if err := h.repoService.TestConnection(ctx, input.ID, input.Branch, actor); err != nil { return nil, huma.Error400BadRequest((&common.GitRepositoryTestError{Err: err}).Error()) @@ -474,10 +330,6 @@ func (h *GitRepositoryHandler) SyncRepositories(ctx context.Context, input *Sync return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.repoService.SyncRepositories(ctx, input.Body.Repositories); err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitRepositorySyncError{Err: err}).Error()) diff --git a/backend/api/handlers/git_repositories_test.go b/backend/api/handlers/git_repositories_test.go index 73694452c7..c1cd984d6d 100644 --- a/backend/api/handlers/git_repositories_test.go +++ b/backend/api/handlers/git_repositories_test.go @@ -3,59 +3,128 @@ package handlers import ( "context" "net/http" + "net/http/httptest" "testing" "github.com/danielgtaylor/huma/v2" - "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" -) -func TestGitRepositoryHandlers_RequireAdmin(t *testing.T) { - handler := &GitRepositoryHandler{repoService: &services.GitRepositoryService{}} + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" +) +// TestGitRepositoryHandlers_PermissionGating verifies that the mutating +// git-repository operations are gated by their RequirePermission middleware: +// a caller without the required permission gets 403 before the handler runs. +// +// We exercise the full Huma operation stack (registration → middleware → handler) +// so the same path that runs in production is what's under test. +func TestGitRepositoryHandlers_PermissionGating(t *testing.T) { tests := []struct { name string - call func() error + // register installs one operation on the API with its real middleware. + register func(api huma.API) + method string + path string }{ { name: "create repository", - call: func() error { - _, err := handler.CreateRepository(context.Background(), &CreateGitRepositoryInput{}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-create-git-repo", + Method: http.MethodPost, + Path: "/customize/git-repositories", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposCreate), + }, func(_ context.Context, _ *struct{}) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodPost, + path: "/api/customize/git-repositories", }, { name: "update repository", - call: func() error { - _, err := handler.UpdateRepository(context.Background(), &UpdateGitRepositoryInput{ID: "repo-1"}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-update-git-repo", + Method: http.MethodPut, + Path: "/customize/git-repositories/{id}", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposUpdate), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodPut, + path: "/api/customize/git-repositories/repo-1", }, { name: "delete repository", - call: func() error { - _, err := handler.DeleteRepository(context.Background(), &DeleteGitRepositoryInput{ID: "repo-1"}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-delete-git-repo", + Method: http.MethodDelete, + Path: "/customize/git-repositories/{id}", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposDelete), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodDelete, + path: "/api/customize/git-repositories/repo-1", }, { name: "test repository", - call: func() error { - _, err := handler.TestRepository(context.Background(), &TestGitRepositoryInput{ID: "repo-1", Branch: "main"}) - return err + register: func(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "test-test-git-repo", + Method: http.MethodPost, + Path: "/customize/git-repositories/{id}/test", + Middlewares: humamw.RequirePermission(api, authz.PermGitReposTest), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") + return nil, nil + }) }, + method: http.MethodPost, + path: "/api/customize/git-repositories/repo-1/test", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.call() - require.Error(t, err) + router := echo.New() + api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) - var statusErr huma.StatusError - require.ErrorAs(t, err, &statusErr) - require.Equal(t, http.StatusForbidden, statusErr.GetStatus()) - require.Contains(t, statusErr.Error(), "admin access required") + // Attach an empty (deny-all) permission set, simulating an + // authenticated caller who lacks git-repository permissions. + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), humamw.ContextKeyUserPermissions, authz.NewPermissionSet()))) + }) + + tt.register(api) + + req := httptest.NewRequest(tt.method, tt.path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "permission denied") }) } } + +// Compile-time assertion that GitRepositoryService is the type the production +// handler expects; keeps this test honest if the field is ever renamed. +var _ = (*GitRepositoryHandler)(&GitRepositoryHandler{repoService: &services.GitRepositoryService{}}) diff --git a/backend/api/handlers/gitops_syncs.go b/backend/api/handlers/gitops_syncs.go index 7e9d0041b3..71f31962b0 100644 --- a/backend/api/handlers/gitops_syncs.go +++ b/backend/api/handlers/gitops_syncs.go @@ -8,7 +8,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/gitops" ) @@ -125,127 +125,34 @@ type ImportGitOpsSyncsOutput struct { func RegisterGitOpsSyncs(api huma.API, syncService *services.GitOpsSyncService) { h := &GitOpsSyncHandler{syncService: syncService} - huma.Register(api, huma.Operation{ - OperationID: "listGitOpsSyncs", - Method: "GET", - Path: "/environments/{id}/gitops-syncs", - Summary: "List GitOps syncs", - Description: "Get a paginated list of GitOps syncs for an environment", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.ListSyncs) - - huma.Register(api, huma.Operation{ - OperationID: "createGitOpsSync", - Method: "POST", - Path: "/environments/{id}/gitops-syncs", - Summary: "Create a GitOps sync", - Description: "Create a new GitOps sync configuration for an environment", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.CreateSync) - - huma.Register(api, huma.Operation{ - OperationID: "importGitOpsSyncs", - Method: "POST", - Path: "/environments/{id}/gitops-syncs/import", - Summary: "Import GitOps syncs", - Description: "Import multiple GitOps sync configurations from JSON", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.ImportSyncs) - - huma.Register(api, huma.Operation{ - OperationID: "getGitOpsSync", - Method: "GET", - Path: "/environments/{id}/gitops-syncs/{syncId}", - Summary: "Get a GitOps sync", - Description: "Get a GitOps sync by ID", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.GetSync) - - huma.Register(api, huma.Operation{ - OperationID: "updateGitOpsSync", - Method: "PUT", - Path: "/environments/{id}/gitops-syncs/{syncId}", - Summary: "Update a GitOps sync", - Description: "Update an existing GitOps sync configuration", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.UpdateSync) - - huma.Register(api, huma.Operation{ - OperationID: "deleteGitOpsSync", - Method: "DELETE", - Path: "/environments/{id}/gitops-syncs/{syncId}", - Summary: "Delete a GitOps sync", - Description: "Delete a GitOps sync configuration by ID", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.DeleteSync) - - huma.Register(api, huma.Operation{ - OperationID: "performGitOpsSync", - Method: "POST", - Path: "/environments/{id}/gitops-syncs/{syncId}/sync", - Summary: "Perform a GitOps sync", - Description: "Manually trigger a sync operation", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - Middlewares: humamw.RequireAdmin(api), - }, h.PerformSync) - - huma.Register(api, huma.Operation{ - OperationID: "getGitOpsSyncStatus", - Method: "GET", - Path: "/environments/{id}/gitops-syncs/{syncId}/status", - Summary: "Get GitOps sync status", - Description: "Get the current status of a GitOps sync", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.GetStatus) - - huma.Register(api, huma.Operation{ - OperationID: "browseGitOpsSyncFiles", - Method: "GET", - Path: "/environments/{id}/gitops-syncs/{syncId}/files", - Summary: "Browse GitOps sync files", - Description: "Browse files in the synced repository", - Tags: []string{"GitOps Syncs"}, - Security: []map[string][]string{ - {"BearerAuth": {}}, - {"ApiKeyAuth": {}}, - }, - }, h.BrowseFiles) + registerGitOpsSecuredInternal(api, "listGitOpsSyncs", "GET", "/environments/{id}/gitops-syncs", "List GitOps syncs", "Get a paginated list of GitOps syncs for an environment", authz.PermGitOpsList, h.ListSyncs) + registerGitOpsSecuredInternal(api, "createGitOpsSync", "POST", "/environments/{id}/gitops-syncs", "Create a GitOps sync", "Create a new GitOps sync configuration for an environment", authz.PermGitOpsCreate, h.CreateSync) + registerGitOpsSecuredInternal(api, "importGitOpsSyncs", "POST", "/environments/{id}/gitops-syncs/import", "Import GitOps syncs", "Import multiple GitOps sync configurations from JSON", authz.PermGitOpsCreate, h.ImportSyncs) + registerGitOpsSecuredInternal(api, "getGitOpsSync", "GET", "/environments/{id}/gitops-syncs/{syncId}", "Get a GitOps sync", "Get a GitOps sync by ID", authz.PermGitOpsRead, h.GetSync) + registerGitOpsSecuredInternal(api, "updateGitOpsSync", "PUT", "/environments/{id}/gitops-syncs/{syncId}", "Update a GitOps sync", "Update an existing GitOps sync configuration", authz.PermGitOpsUpdate, h.UpdateSync) + registerGitOpsSecuredInternal(api, "deleteGitOpsSync", "DELETE", "/environments/{id}/gitops-syncs/{syncId}", "Delete a GitOps sync", "Delete a GitOps sync configuration by ID", authz.PermGitOpsDelete, h.DeleteSync) + registerGitOpsSecuredInternal(api, "performGitOpsSync", "POST", "/environments/{id}/gitops-syncs/{syncId}/sync", "Perform a GitOps sync", "Manually trigger a sync operation", authz.PermGitOpsSync, h.PerformSync) + registerGitOpsSecuredInternal(api, "getGitOpsSyncStatus", "GET", "/environments/{id}/gitops-syncs/{syncId}/status", "Get GitOps sync status", "Get the current status of a GitOps sync", authz.PermGitOpsRead, h.GetStatus) + 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") } // ============================================================================ @@ -258,7 +165,7 @@ func (h *GitOpsSyncHandler) ListSyncs(ctx context.Context, input *ListGitOpsSync return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) syncs, paginationResp, counts, err := h.syncService.GetSyncsPaginated(ctx, input.EnvironmentID, params) if err != nil { @@ -283,50 +190,41 @@ func (h *GitOpsSyncHandler) ListSyncs(ctx context.Context, input *ListGitOpsSync // CreateSync creates a new GitOps sync. func (h *GitOpsSyncHandler) CreateSync(ctx context.Context, input *CreateGitOpsSyncInput) (*CreateGitOpsSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser + 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) if err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitOpsSyncCreationError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitOpsSync, gitops.GitOpsSync](sync) + body, mapErr := mapOneAPIResponseInternal[*models.GitOpsSync, gitops.GitOpsSync](sync, func(err error) string { + return (&common.GitOpsSyncMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitOpsSyncMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &CreateGitOpsSyncOutput{ - Body: base.ApiResponse[gitops.GitOpsSync]{ - Success: true, - Data: out, - }, + Body: body, }, nil } // ImportSyncs imports multiple GitOps syncs. func (h *GitOpsSyncHandler) ImportSyncs(ctx context.Context, input *ImportGitOpsSyncsInput) (*ImportGitOpsSyncsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) response, err := h.syncService.ImportSyncs(ctx, input.EnvironmentID, input.Body, actor) if err != nil { @@ -353,65 +251,55 @@ func (h *GitOpsSyncHandler) GetSync(ctx context.Context, input *GetGitOpsSyncInp return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitOpsSyncRetrievalError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitOpsSync, gitops.GitOpsSync](sync) + body, mapErr := mapOneAPIResponseInternal[*models.GitOpsSync, gitops.GitOpsSync](sync, func(err error) string { + return (&common.GitOpsSyncMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitOpsSyncMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &GetGitOpsSyncOutput{ - Body: base.ApiResponse[gitops.GitOpsSync]{ - Success: true, - Data: out, - }, + Body: body, }, nil } // UpdateSync updates an existing GitOps sync. func (h *GitOpsSyncHandler) UpdateSync(ctx context.Context, input *UpdateGitOpsSyncInput) (*UpdateGitOpsSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser + 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) if err != nil { apiErr := models.ToAPIError(err) return nil, huma.NewError(apiErr.HTTPStatus(), (&common.GitOpsSyncUpdateError{Err: err}).Error()) } - out, mapErr := mapper.MapOne[*models.GitOpsSync, gitops.GitOpsSync](sync) + body, mapErr := mapOneAPIResponseInternal[*models.GitOpsSync, gitops.GitOpsSync](sync, func(err error) string { + return (&common.GitOpsSyncMappingError{Err: err}).Error() + }) if mapErr != nil { - return nil, huma.Error500InternalServerError((&common.GitOpsSyncMappingError{Err: mapErr}).Error()) + return nil, mapErr } return &UpdateGitOpsSyncOutput{ - Body: base.ApiResponse[gitops.GitOpsSync]{ - Success: true, - Data: out, - }, + Body: body, }, nil } // DeleteSync deletes a GitOps sync by ID. func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsSyncInput) (*DeleteGitOpsSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) if err := h.syncService.DeleteSync(ctx, input.EnvironmentID, input.SyncID, actor); err != nil { apiErr := models.ToAPIError(err) @@ -430,17 +318,11 @@ func (h *GitOpsSyncHandler) DeleteSync(ctx context.Context, input *DeleteGitOpsS // PerformSync manually triggers a sync operation. func (h *GitOpsSyncHandler) PerformSync(ctx context.Context, input *PerformSyncInput) (*PerformSyncOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.syncService == nil { return nil, huma.Error500InternalServerError("service not available") } - actor := models.User{} - if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { - actor = *currentUser - } + actor := currentActorInternal(ctx) result, err := h.syncService.PerformSync(ctx, input.EnvironmentID, input.SyncID, actor) if err != nil { diff --git a/backend/api/handlers/helpers.go b/backend/api/handlers/helpers.go index e006f68a98..8552ca6d3f 100644 --- a/backend/api/handlers/helpers.go +++ b/backend/api/handlers/helpers.go @@ -7,36 +7,42 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/remenv" + "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" + "github.com/getarcaneapp/arcane/types/base" ) -// checkAdminInternal checks if the current user is an admin and returns a 403 error if not. -func checkAdminInternal(ctx context.Context) error { - if !humamw.IsAdminFromContext(ctx) { - return huma.Error403Forbidden("admin access required") - } - return nil +// ActivityAppContext carries the app lifecycle context through handler registration. +type ActivityAppContext struct { + ctx context.Context +} + +// NewActivityAppContext wraps the app lifecycle context for handler constructors. +func NewActivityAppContext(ctx context.Context) ActivityAppContext { + return ActivityAppContext{ctx: ctx} +} + +// ContextInternal returns the wrapped app lifecycle context. +func (c ActivityAppContext) ContextInternal() context.Context { + return c.contextInternal() +} + +func (c ActivityAppContext) contextInternal() context.Context { + return c.ctx } // buildPaginationParamsInternal converts query parameters to pagination.QueryParams. -// It supports both the legacy nested style (page/limit) and the standard style (start/limit). // A limit of -1 means "show all items" (no pagination). -func buildPaginationParamsInternal(page, start, limit int, sortCol, sortDir, search string) pagination.QueryParams { +func buildPaginationParamsInternal(start, limit int, sortCol, sortDir, search string) pagination.QueryParams { // limit = -1 means "show all", preserve it; zero or other negative values default to 20 if limit < -1 { limit = 20 } - finalStart := start - if page > 1 && start == 0 && limit > 0 { - // Convert page-based to offset-based if page is provided and start is 0 - // Skip this conversion when limit is -1 (show all) - finalStart = (page - 1) * limit - } - - params := pagination.QueryParams{ + return pagination.QueryParams{ SearchQuery: pagination.SearchQuery{ Search: search, }, @@ -44,13 +50,101 @@ func buildPaginationParamsInternal(page, start, limit int, sortCol, sortDir, sea Sort: sortCol, Order: pagination.SortOrder(sortDir), }, - PaginationParams: pagination.PaginationParams{ - Start: finalStart, + Params: pagination.Params{ + Start: start, Limit: limit, }, Filters: make(map[string]string), } - return params +} + +func registerSecuredInternal[I, O any]( + api huma.API, + op huma.Operation, + permission string, + handler func(context.Context, *I) (*O, error), +) { + op.Security = defaultOperationSecurityInternal() + op.Middlewares = humamw.RequirePermission(api, permission) + huma.Register(api, op, handler) +} + +func registerCustomizeSecuredInternal[I, O any]( + api huma.API, + operationID string, + method string, + path string, + summary string, + description string, + permission string, + handler func(context.Context, *I) (*O, error), +) { + registerTaggedSecuredInternal(api, operationID, method, path, summary, description, "Customize", permission, handler) +} + +func registerGitOpsSecuredInternal[I, O any]( + api huma.API, + operationID string, + method string, + path string, + summary string, + description string, + permission string, + handler func(context.Context, *I) (*O, error), +) { + registerTaggedSecuredInternal(api, operationID, method, path, summary, description, "GitOps Syncs", permission, handler) +} + +func registerTaggedSecuredInternal[I, O any]( + api huma.API, + operationID string, + method string, + path string, + summary string, + description string, + tag string, + permission string, + handler func(context.Context, *I) (*O, error), +) { + registerSecuredInternal(api, operationInternal(operationID, method, path, summary, description, tag), permission, handler) +} + +func operationInternal(operationID, method, path, summary, description string, tags ...string) huma.Operation { + return huma.Operation{ + OperationID: operationID, + Method: method, + Path: path, + Summary: summary, + Description: description, + Tags: tags, + } +} + +func currentActorInternal(ctx context.Context) models.User { + actor := models.User{} + if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { + actor = *currentUser + } + return actor +} + +func mapOneAPIResponseInternal[S any, D any](source S, mappingMessage func(error) string) (base.ApiResponse[D], error) { + out, err := mapper.MapOne[S, D](source) + if err != nil { + return base.ApiResponse[D]{}, huma.Error500InternalServerError(mappingMessage(err)) + } + + return base.ApiResponse[D]{ + Success: true, + Data: out, + }, nil +} + +func defaultOperationSecurityInternal() []map[string][]string { + return []map[string][]string{ + {"BearerAuth": {}}, + {"ApiKeyAuth": {}}, + } } func proxyRemoteJSONInternal[T any]( @@ -86,18 +180,15 @@ func marshalRemoteRequestBodyInternal(requestBody any) ([]byte, error) { } func translateRemoteProxyErrorInternal(err error) error { - var transportErr *remenv.TransportError - if errors.As(err, &transportErr) { + if transportErr, ok := errors.AsType[*remenv.TransportError](err); ok { return huma.Error502BadGateway("failed to proxy request to environment: " + transportErr.Error()) } - var statusErr *remenv.StatusError - if errors.As(err, &statusErr) { + if statusErr, ok := errors.AsType[*remenv.StatusError](err); ok { return huma.NewError(statusErr.StatusCode, "environment returned error: "+string(statusErr.Body), nil) } - var decodeErr *remenv.DecodeError - if errors.As(err, &decodeErr) { + if decodeErr, ok := errors.AsType[*remenv.DecodeError](err); ok { return huma.Error500InternalServerError("failed to decode environment response: " + decodeErr.Error()) } diff --git a/backend/api/handlers/image_updates.go b/backend/api/handlers/image_updates.go index 79a4822070..fe859a5918 100644 --- a/backend/api/handlers/image_updates.go +++ b/backend/api/handlers/image_updates.go @@ -6,8 +6,11 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" imagetypes "github.com/getarcaneapp/arcane/types/image" "github.com/getarcaneapp/arcane/types/imageupdate" @@ -16,6 +19,7 @@ import ( type ImageUpdateHandler struct { imageUpdateService *services.ImageUpdateService imageService *services.ImageService + appCtx context.Context } type CheckImageUpdateInput struct { @@ -72,10 +76,11 @@ type GetUpdateSummaryOutput struct { } // RegisterImageUpdates registers image update endpoints. -func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateService, imageSvc *services.ImageService) { +func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateService, imageSvc *services.ImageService, appCtx ActivityAppContext) { h := &ImageUpdateHandler{ imageUpdateService: imageUpdateSvc, imageService: imageSvc, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -85,6 +90,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check image update by reference", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckImageUpdate) huma.Register(api, huma.Operation{ @@ -94,6 +100,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check image update by ID", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckImageUpdateByID) huma.Register(api, huma.Operation{ @@ -103,6 +110,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check image update by ID (POST)", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckImageUpdateByID) huma.Register(api, huma.Operation{ @@ -112,6 +120,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check multiple images", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckMultipleImages) huma.Register(api, huma.Operation{ @@ -121,6 +130,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Check all images", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.CheckAllImages) huma.Register(api, huma.Operation{ @@ -130,6 +140,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Get persisted update info for image references", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdateInfoByRefs) huma.Register(api, huma.Operation{ @@ -139,6 +150,7 @@ func RegisterImageUpdates(api huma.API, imageUpdateSvc *services.ImageUpdateServ Summary: "Get update summary", Tags: []string{"Image Updates"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdateSummary) } @@ -147,7 +159,8 @@ func (h *ImageUpdateHandler) CheckImageUpdate(ctx context.Context, input *CheckI return nil, huma.Error400BadRequest((&common.ImageRefRequiredError{}).Error()) } - result, err := h.imageUpdateService.CheckImageUpdate(ctx, input.ImageRef) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.imageUpdateService.CheckImageUpdate(runtimeCtx, input.ImageRef) if err != nil { return nil, huma.Error500InternalServerError((&common.ImageUpdateCheckError{Err: err}).Error()) } @@ -165,7 +178,8 @@ func (h *ImageUpdateHandler) CheckImageUpdateByID(ctx context.Context, input *Ch return nil, huma.Error400BadRequest((&common.ImageIDRequiredError{}).Error()) } - result, err := h.imageUpdateService.CheckImageUpdateByID(ctx, input.ImageID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.imageUpdateService.CheckImageUpdateByID(runtimeCtx, input.ImageID) if err != nil { return nil, huma.Error500InternalServerError((&common.ImageUpdateCheckError{Err: err}).Error()) } @@ -189,7 +203,8 @@ func (h *ImageUpdateHandler) CheckMultipleImages(ctx context.Context, input *Che }, nil } - results, err := h.imageUpdateService.CheckMultipleImages(ctx, input.Body.ImageRefs, input.Body.Credentials) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + results, err := h.imageUpdateService.CheckMultipleImages(runtimeCtx, input.Body.ImageRefs, input.Body.Credentials) if err != nil { return nil, huma.Error500InternalServerError((&common.BatchImageUpdateCheckError{Err: err}).Error()) } @@ -203,7 +218,8 @@ func (h *ImageUpdateHandler) CheckMultipleImages(ctx context.Context, input *Che } func (h *ImageUpdateHandler) CheckAllImages(ctx context.Context, input *CheckAllImagesInput) (*CheckAllImagesOutput, error) { - results, err := h.imageUpdateService.CheckAllImages(ctx, 0, input.Body.Credentials) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + results, err := h.imageUpdateService.CheckAllImages(runtimeCtx, 0, input.Body.Credentials) if err != nil { return nil, huma.Error500InternalServerError((&common.AllImageUpdateCheckError{Err: err}).Error()) } diff --git a/backend/api/handlers/images.go b/backend/api/handlers/images.go index 00207682e2..59b1a74260 100644 --- a/backend/api/handlers/images.go +++ b/backend/api/handlers/images.go @@ -11,8 +11,13 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/image" "github.com/getarcaneapp/arcane/types/system" @@ -26,6 +31,8 @@ type ImageHandler struct { imageUpdateService *services.ImageUpdateService settingsService *services.SettingsService buildService *services.BuildService + activityService *services.ActivityService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -149,13 +156,15 @@ type UploadImageOutput struct { } // RegisterImages registers image management routes using Huma. -func RegisterImages(api huma.API, dockerService *services.DockerClientService, imageService *services.ImageService, imageUpdateService *services.ImageUpdateService, settingsService *services.SettingsService, buildService *services.BuildService) { +func RegisterImages(api huma.API, dockerService *services.DockerClientService, imageService *services.ImageService, imageUpdateService *services.ImageUpdateService, settingsService *services.SettingsService, buildService *services.BuildService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &ImageHandler{ dockerService: dockerService, imageService: imageService, imageUpdateService: imageUpdateService, settingsService: settingsService, buildService: buildService, + activityService: activityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -169,6 +178,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesList), }, h.ListImages) huma.Register(api, huma.Operation{ @@ -182,6 +192,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesList), }, h.GetImageUsageCounts) huma.Register(api, huma.Operation{ @@ -195,6 +206,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesRead), }, h.GetImage) huma.Register(api, huma.Operation{ @@ -208,7 +220,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesDelete), }, h.RemoveImage) huma.Register(api, huma.Operation{ @@ -222,7 +234,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesPull), }, h.PullImage) huma.Register(api, huma.Operation{ @@ -236,7 +248,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesBuild), }, h.BuildImage) huma.Register(api, huma.Operation{ @@ -250,6 +262,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesList), }, h.ListImageBuilds) huma.Register(api, huma.Operation{ @@ -263,6 +276,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImagesRead), }, h.GetImageBuild) huma.Register(api, huma.Operation{ @@ -276,7 +290,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesPrune), }, h.PruneImages) huma.Register(api, huma.Operation{ @@ -307,7 +321,7 @@ func RegisterImages(api huma.API, dockerService *services.DockerClientService, i }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImagesUpload), }, h.UploadImage) } @@ -333,7 +347,7 @@ func (h *ImageHandler) ListImages(ctx context.Context, input *ListImagesInput) ( Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -389,9 +403,6 @@ func (h *ImageHandler) GetImage(ctx context.Context, input *GetImageInput) (*Get // RemoveImage removes a Docker image. func (h *ImageHandler) RemoveImage(ctx context.Context, input *RemoveImageInput) (*RemoveImageOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -417,9 +428,6 @@ func (h *ImageHandler) RemoveImage(ctx context.Context, input *RemoveImageInput) // PullImage pulls a Docker image with streaming progress. func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -439,26 +447,43 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") - - writer := humaCtx.BodyWriter() + httpx.SetJSONStreamHeaders(humaCtx) + + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + rawWriter := humaCtx.BodyWriter() + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( + runtimeCtx, + h.activityService, + input.EnvironmentID, + models.ActivityTypeImagePull, + "image", + "", + fullImageName, + user, + "Pulling image", + "Image pull started", + models.JSON{"imageName": fullImageName}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + if f, ok := rawWriter.(http.Flusher); ok { + f.Flush() + } - if err := h.imageService.PullImage(humaCtx.Context(), fullImageName, writer, *user, credentials); err != nil { + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Pulling image") + if err := h.imageService.PullImage(runtimeCtx, fullImageName, writer, *user, credentials); err != nil { + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image pull failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) return } + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image pull completed", nil) }, }, nil } // BuildImage builds a Docker image with streaming progress. func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.buildService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -474,16 +499,41 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) ( return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") + httpx.SetJSONStreamHeaders(humaCtx) - writer := humaCtx.BodyWriter() - if _, err := h.buildService.BuildImage(humaCtx.Context(), input.EnvironmentID, input.Body, writer, "", user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + rawWriter := humaCtx.BodyWriter() + resourceName := strings.Join(input.Body.Tags, ", ") + if strings.TrimSpace(resourceName) == "" { + resourceName = input.Body.ContextDir + } + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( + runtimeCtx, + h.activityService, + input.EnvironmentID, + models.ActivityTypeImageBuild, + "image", + "", + resourceName, + user, + "Building image", + "Image build started", + models.JSON{"contextDir": input.Body.ContextDir, "tags": input.Body.Tags}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + if f, ok := rawWriter.(http.Flusher); ok { + f.Flush() + } + + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Building image") + if _, err := h.buildService.BuildImage(runtimeCtx, input.EnvironmentID, input.Body, writer, "", user); err != nil { + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image build failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) return } + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Image build completed", nil) }, }, nil } @@ -498,7 +548,7 @@ func (h *ImageHandler) ListImageBuilds(ctx context.Context, input *ListImageBuil return nil, huma.Error400BadRequest((&common.EnvironmentIDRequiredError{}).Error()) } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if input.Status != "" { params.Filters["status"] = input.Status } @@ -562,9 +612,6 @@ func (h *ImageHandler) GetImageBuild(ctx context.Context, input *GetImageBuildIn // PruneImages removes unused Docker images. func (h *ImageHandler) PruneImages(ctx context.Context, input *PruneImagesInput) (*PruneImagesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -687,9 +734,6 @@ func (h *ImageHandler) GetImageUsageCounts(ctx context.Context, input *GetImageU // UploadImage uploads a Docker image from a tar archive. func (h *ImageHandler) UploadImage(ctx context.Context, input *UploadImageInput) (*UploadImageOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.imageService == nil || h.settingsService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/job_schedules.go b/backend/api/handlers/job_schedules.go index cf00e585fb..454e2c108c 100644 --- a/backend/api/handlers/job_schedules.go +++ b/backend/api/handlers/job_schedules.go @@ -7,6 +7,7 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/jobschedule" ) @@ -62,6 +63,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.Get) huma.Register(api, huma.Operation{ @@ -75,7 +77,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.Update) huma.Register(api, huma.Operation{ @@ -89,6 +91,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.ListJobs) huma.Register(api, huma.Operation{ @@ -102,7 +105,7 @@ func RegisterJobSchedules(api huma.API, jobSvc *services.JobService, envSvc *ser {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermJobsManage), }, h.RunJob) } @@ -136,9 +139,6 @@ func (h *JobSchedulesHandler) ListJobs(ctx context.Context, input *ListJobsInput } func (h *JobSchedulesHandler) RunJob(ctx context.Context, input *RunJobInput) (*RunJobOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.jobService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -188,9 +188,6 @@ func (h *JobSchedulesHandler) Get(ctx context.Context, input *GetJobSchedulesInp } func (h *JobSchedulesHandler) Update(ctx context.Context, input *UpdateJobSchedulesInput) (*UpdateJobSchedulesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.jobService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/networks.go b/backend/api/handlers/networks.go index adb89db55a..e39f7bf0c4 100644 --- a/backend/api/handlers/networks.go +++ b/backend/api/handlers/networks.go @@ -11,8 +11,12 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" networktypes "github.com/getarcaneapp/arcane/types/network" @@ -20,8 +24,10 @@ import ( ) type NetworkHandler struct { - networkService *services.NetworkService - dockerService *services.DockerClientService + networkService *services.NetworkService + dockerService *services.DockerClientService + activityService *services.ActivityService + appCtx context.Context } type NetworkPaginatedResponse struct { @@ -132,10 +138,12 @@ type PruneNetworksOutput struct { } // RegisterNetworks registers network endpoints. -func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerSvc *services.DockerClientService) { +func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerSvc *services.DockerClientService, activitySvc *services.ActivityService, appCtx ActivityAppContext) { h := &NetworkHandler{ - networkService: networkSvc, - dockerService: dockerSvc, + networkService: networkSvc, + dockerService: dockerSvc, + activityService: activitySvc, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -145,6 +153,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "List networks", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksList), }, h.ListNetworks) huma.Register(api, huma.Operation{ @@ -154,6 +163,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Network counts", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksList), }, h.GetNetworkCounts) huma.Register(api, huma.Operation{ @@ -163,7 +173,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Create network", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNetworksCreate), }, h.CreateNetwork) huma.Register(api, huma.Operation{ @@ -173,6 +183,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Get network topology", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksRead), }, h.GetNetworkTopology) huma.Register(api, huma.Operation{ @@ -182,6 +193,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Get network", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNetworksRead), }, h.GetNetwork) huma.Register(api, huma.Operation{ @@ -191,7 +203,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Delete network", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNetworksDelete), }, h.DeleteNetwork) huma.Register(api, huma.Operation{ @@ -201,7 +213,7 @@ func RegisterNetworks(api huma.API, networkSvc *services.NetworkService, dockerS Summary: "Prune networks", Tags: []string{"Networks"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNetworksPrune), }, h.PruneNetworks) } @@ -219,7 +231,7 @@ func (h *NetworkHandler) ListNetworks(ctx context.Context, input *ListNetworksIn Sort: strings.TrimSpace(input.Sort), Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -266,9 +278,6 @@ func (h *NetworkHandler) GetNetworkCounts(ctx context.Context, input *GetNetwork } func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetworkInput) (*CreateNetworkOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized("not authenticated") @@ -277,7 +286,27 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork // Convert to Docker SDK options dockerOptions := input.Body.Options.ToDockerCreateOptions() - response, err := h.networkService.CreateNetwork(ctx, input.Body.Name, dockerOptions, *user) + var response *dockernetwork.CreateResponse + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "network", + ResourceID: input.Body.Name, + ResourceName: input.Body.Name, + User: user, + Step: "Creating network", + Message: "Creating network", + SuccessMessage: "Network created successfully", + Metadata: models.JSON{ + "action": "create_network", + "driver": input.Body.Options.Driver, + }, + }, func(runtimeCtx context.Context) error { + var createErr error + response, createErr = h.networkService.CreateNetwork(runtimeCtx, input.Body.Name, dockerOptions, *user) + return createErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkCreationError{Err: err}).Error()) } @@ -286,6 +315,7 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkMappingError{Err: err}).Error()) } + out.ActivityID = utils.StringPtrFromTrimmed(activityID) return &CreateNetworkOutput{ Body: NetworkCreatedApiResponse{ @@ -398,31 +428,56 @@ func (h *NetworkHandler) GetNetworkTopology(ctx context.Context, input *GetNetwo } func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetworkInput) (*DeleteNetworkOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized("not authenticated") } - if err := h.networkService.RemoveNetwork(ctx, input.NetworkID, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "network", + ResourceID: input.NetworkID, + ResourceName: input.NetworkID, + User: user, + Step: "Removing network", + Message: "Removing network", + SuccessMessage: "Network removed successfully", + Metadata: models.JSON{ + "action": "remove_network", + }, + }, func(runtimeCtx context.Context) error { + return h.networkService.RemoveNetwork(runtimeCtx, input.NetworkID, *user) + }) + if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkRemovalError{Err: err}).Error()) } return &DeleteNetworkOutput{ Body: NetworkMessageApiResponse{ Success: true, - Data: base.MessageResponse{Message: "Network removed successfully"}, + Data: base.MessageResponse{Message: "Network removed successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworksInput) (*PruneNetworksOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - report, err := h.networkService.PruneNetworks(ctx) + var report *dockernetwork.PruneReport + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "network", + Step: "Pruning unused networks", + Message: "Pruning unused networks", + SuccessMessage: "Networks pruned successfully", + Metadata: models.JSON{"action": "prune_networks"}, + }, func(runtimeCtx context.Context) error { + var pruneErr error + report, pruneErr = h.networkService.PruneNetworks(runtimeCtx) + return pruneErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkPruneError{Err: err}).Error()) } @@ -431,6 +486,7 @@ func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworks if err != nil { return nil, huma.Error500InternalServerError((&common.NetworkMappingError{Err: err}).Error()) } + out.ActivityID = utils.StringPtrFromTrimmed(activityID) return &PruneNetworksOutput{ Body: NetworkPruneResponse{ diff --git a/backend/api/handlers/notifications.go b/backend/api/handlers/notifications.go index 340b4c133c..353fad9e4c 100644 --- a/backend/api/handlers/notifications.go +++ b/backend/api/handlers/notifications.go @@ -12,13 +12,13 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/notification" ) type NotificationHandler struct { notificationService *services.NotificationService - appriseService *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr config *config.Config } @@ -67,32 +67,6 @@ type TestNotificationOutput struct { Body base.ApiResponse[base.MessageResponse] } -type GetAppriseSettingsInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` -} - -type GetAppriseSettingsOutput struct { - Body notification.AppriseResponse -} - -type CreateOrUpdateAppriseSettingsInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` - Body notification.AppriseUpdate -} - -type CreateOrUpdateAppriseSettingsOutput struct { - Body notification.AppriseResponse -} - -type TestAppriseNotificationInput struct { - EnvironmentID string `path:"id" doc:"Environment ID"` - Type string `query:"type" default:"simple"` -} - -type TestAppriseNotificationOutput struct { - Body base.ApiResponse[base.MessageResponse] -} - type DispatchNotificationInput struct { APIKey string `header:"X-API-Key" doc:"Remote environment access token"` Body notification.DispatchRequest @@ -125,12 +99,9 @@ func isSupportedNotificationTestType(testType string) bool { } // RegisterNotifications registers notification endpoints. -// -//nolint:staticcheck // AppriseService still functional, deprecated in favor of Shoutrrr -func RegisterNotifications(api huma.API, notificationSvc *services.NotificationService, appriseSvc *services.AppriseService, cfg *config.Config) { +func RegisterNotifications(api huma.API, notificationSvc *services.NotificationService, cfg *config.Config) { h := &NotificationHandler{ notificationService: notificationSvc, - appriseService: appriseSvc, config: cfg, } @@ -141,7 +112,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Get all notification settings", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.GetAllNotificationSettings) huma.Register(api, huma.Operation{ @@ -151,7 +122,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Get notification settings by provider", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.GetNotificationSettings) huma.Register(api, huma.Operation{ @@ -161,7 +132,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Create or update notification settings", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.CreateOrUpdateNotificationSettings) huma.Register(api, huma.Operation{ @@ -171,7 +142,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Delete notification settings", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.DeleteNotificationSettings) huma.Register(api, huma.Operation{ @@ -181,39 +152,9 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Test notification", Tags: []string{"Notifications"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.TestNotification) - huma.Register(api, huma.Operation{ - OperationID: "get-apprise-settings", - Method: http.MethodGet, - Path: "/environments/{id}/notifications/apprise", - Summary: "Get Apprise settings", - Tags: []string{"Notifications"}, - Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), - }, h.GetAppriseSettings) - - huma.Register(api, huma.Operation{ - OperationID: "create-or-update-apprise-settings", - Method: http.MethodPost, - Path: "/environments/{id}/notifications/apprise", - Summary: "Create or update Apprise settings", - Tags: []string{"Notifications"}, - Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), - }, h.CreateOrUpdateAppriseSettings) - - huma.Register(api, huma.Operation{ - OperationID: "test-apprise-notification", - Method: http.MethodPost, - Path: "/environments/{id}/notifications/apprise/test", - Summary: "Test Apprise notification", - Tags: []string{"Notifications"}, - Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, - Middlewares: humamw.RequireAdmin(api), - }, h.TestAppriseNotification) - huma.Register(api, huma.Operation{ OperationID: "dispatch-notification", Method: http.MethodPost, @@ -221,6 +162,7 @@ func RegisterNotifications(api huma.API, notificationSvc *services.NotificationS Summary: "Dispatch notification from remote agent to manager", Tags: []string{"Notifications"}, Security: []map[string][]string{{"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermNotificationsManage), }, h.DispatchNotification) } @@ -232,9 +174,6 @@ func (h *NotificationHandler) rejectIfAgentModeInternal() error { } func (h *NotificationHandler) GetAllNotificationSettings(ctx context.Context, input *GetAllNotificationSettingsInput) (*GetAllNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -257,9 +196,6 @@ func (h *NotificationHandler) GetAllNotificationSettings(ctx context.Context, in } func (h *NotificationHandler) GetNotificationSettings(ctx context.Context, input *GetNotificationSettingsInput) (*GetNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -281,9 +217,6 @@ func (h *NotificationHandler) GetNotificationSettings(ctx context.Context, input } func (h *NotificationHandler) CreateOrUpdateNotificationSettings(ctx context.Context, input *CreateOrUpdateNotificationSettingsInput) (*CreateOrUpdateNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -313,9 +246,6 @@ func (h *NotificationHandler) CreateOrUpdateNotificationSettings(ctx context.Con } func (h *NotificationHandler) DeleteNotificationSettings(ctx context.Context, input *DeleteNotificationSettingsInput) (*DeleteNotificationSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -334,9 +264,6 @@ func (h *NotificationHandler) DeleteNotificationSettings(ctx context.Context, in } func (h *NotificationHandler) TestNotification(ctx context.Context, input *TestNotificationInput) (*TestNotificationOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err } @@ -358,92 +285,6 @@ func (h *NotificationHandler) TestNotification(ctx context.Context, input *TestN }, nil } -func (h *NotificationHandler) GetAppriseSettings(ctx context.Context, input *GetAppriseSettingsInput) (*GetAppriseSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.rejectIfAgentModeInternal(); err != nil { - return nil, err - } - settings, err := h.appriseService.GetSettings(ctx) - if err != nil { - return nil, huma.Error500InternalServerError("Failed to retrieve Apprise settings", err) - } - if settings == nil { - return nil, huma.Error404NotFound((&common.AppriseSettingsNotFoundError{}).Error()) - } - - response := notification.AppriseResponse{ - ID: settings.ID, - APIURL: settings.APIURL, - Enabled: settings.Enabled, - ImageUpdateTag: settings.ImageUpdateTag, - ContainerUpdateTag: settings.ContainerUpdateTag, - } - - return &GetAppriseSettingsOutput{Body: response}, nil -} - -func (h *NotificationHandler) CreateOrUpdateAppriseSettings(ctx context.Context, input *CreateOrUpdateAppriseSettingsInput) (*CreateOrUpdateAppriseSettingsOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.rejectIfAgentModeInternal(); err != nil { - return nil, err - } - if input.Body.Enabled && input.Body.APIURL == "" { - return nil, huma.Error400BadRequest("API URL is required when Apprise is enabled") - } - - settings, err := h.appriseService.CreateOrUpdateSettings( - ctx, - input.Body.APIURL, - input.Body.Enabled, - input.Body.ImageUpdateTag, - input.Body.ContainerUpdateTag, - ) - if err != nil { - return nil, huma.Error500InternalServerError((&common.AppriseSettingsUpdateError{Err: err}).Error()) - } - - response := notification.AppriseResponse{ - ID: settings.ID, - APIURL: settings.APIURL, - Enabled: settings.Enabled, - ImageUpdateTag: settings.ImageUpdateTag, - ContainerUpdateTag: settings.ContainerUpdateTag, - } - - return &CreateOrUpdateAppriseSettingsOutput{Body: response}, nil -} - -func (h *NotificationHandler) TestAppriseNotification(ctx context.Context, input *TestAppriseNotificationInput) (*TestAppriseNotificationOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.rejectIfAgentModeInternal(); err != nil { - return nil, err - } - testType := normalizeNotificationTestType(input.Type) - if !isSupportedNotificationTestType(testType) { - return nil, huma.Error400BadRequest("invalid notification test type") - } - target, err := h.notificationService.ResolveNotificationTarget(ctx, input.EnvironmentID) - if err != nil { - return nil, huma.Error500InternalServerError((&common.AppriseTestError{Err: err}).Error()) - } - if err := h.appriseService.TestNotification(ctx, target.EnvironmentName, testType); err != nil { - return nil, huma.Error500InternalServerError((&common.AppriseTestError{Err: err}).Error()) - } - - return &TestAppriseNotificationOutput{ - Body: base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{Message: "Test notification sent successfully"}, - }, - }, nil -} - func (h *NotificationHandler) DispatchNotification(ctx context.Context, input *DispatchNotificationInput) (*DispatchNotificationOutput, error) { if err := h.rejectIfAgentModeInternal(); err != nil { return nil, err diff --git a/backend/api/handlers/notifications_test.go b/backend/api/handlers/notifications_test.go index d4c13fdf9b..5452a7288e 100644 --- a/backend/api/handlers/notifications_test.go +++ b/backend/api/handlers/notifications_test.go @@ -23,7 +23,7 @@ func setupNotificationHandlerTestService(t *testing.T) (*database.DB, *services. db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{}, &models.AppriseSettings{})) + require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{})) databaseDB := &database.DB{DB: db} envSvc := services.NewEnvironmentService(databaseDB, nil, nil, nil, nil, nil) diff --git a/backend/api/handlers/oidc.go b/backend/api/handlers/oidc.go index c98175eafa..cce1b77cf1 100644 --- a/backend/api/handlers/oidc.go +++ b/backend/api/handlers/oidc.go @@ -7,19 +7,24 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" httputils "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/types/auth" - "github.com/getarcaneapp/arcane/types/user" + roletypes "github.com/getarcaneapp/arcane/types/role" ) -// OidcHandler handles OIDC authentication endpoints. +// OidcHandler handles OIDC authentication endpoints, plus OIDC group → role +// mapping management (since mappings only make sense in the OIDC context). type OidcHandler struct { authService *services.AuthService oidcService *services.OidcService + roleService *services.RoleService + userService *services.UserService config *config.Config } @@ -43,6 +48,7 @@ type GetOidcStatusOutput struct { type GetOidcAuthUrlInput struct { OidcHeaders + Body auth.OidcAuthUrlRequest } @@ -53,6 +59,7 @@ type GetOidcAuthUrlOutput struct { type HandleOidcCallbackInput struct { OidcHeaders + OidcStateCookie string `cookie:"oidc_state" doc:"OIDC state cookie from auth URL request"` Body auth.OidcCallbackRequest } @@ -86,13 +93,59 @@ type ExchangeDeviceTokenOutput struct { Body auth.OidcDeviceTokenResponse } +// --- OIDC role mapping I/O --- + +type ListOidcRoleMappingsInput struct{} + +type ListOidcRoleMappingsOutput struct { + Body struct { + Success bool `json:"success"` + Data []roletypes.OidcRoleMapping `json:"data"` + } +} + +type CreateOidcRoleMappingInput struct { + Body roletypes.CreateOidcRoleMapping +} + +type CreateOidcRoleMappingOutput struct { + Body struct { + Success bool `json:"success"` + Data roletypes.OidcRoleMapping `json:"data"` + } +} + +type UpdateOidcRoleMappingInput struct { + ID string `path:"id" doc:"Mapping ID"` + Body roletypes.UpdateOidcRoleMapping +} + +type UpdateOidcRoleMappingOutput struct { + Body struct { + Success bool `json:"success"` + Data roletypes.OidcRoleMapping `json:"data"` + } +} + +type DeleteOidcRoleMappingInput struct { + ID string `path:"id" doc:"Mapping ID"` +} + +type DeleteOidcRoleMappingOutput struct { + Body struct { + Success bool `json:"success"` + Message string `json:"message"` + } +} + // ============================================================================ // Registration // ============================================================================ -// RegisterOidc registers all OIDC authentication endpoints using Huma. -func RegisterOidc(api huma.API, authService *services.AuthService, oidcService *services.OidcService, cfg *config.Config) { - h := &OidcHandler{authService: authService, oidcService: oidcService, config: cfg} +// RegisterOidc registers all OIDC authentication endpoints (plus the OIDC +// group → role mapping CRUD) using Huma. +func RegisterOidc(api huma.API, authService *services.AuthService, oidcService *services.OidcService, roleService *services.RoleService, userService *services.UserService, cfg *config.Config) { + h := &OidcHandler{authService: authService, oidcService: oidcService, roleService: roleService, userService: userService, config: cfg} huma.Register(api, huma.Operation{ OperationID: "get-oidc-status", @@ -153,6 +206,49 @@ func RegisterOidc(api huma.API, authService *services.AuthService, oidcService * Tags: []string{"OIDC"}, Security: []map[string][]string{}, }, h.ExchangeDeviceToken) + + // --- OIDC role mapping endpoints --- + + huma.Register(api, huma.Operation{ + OperationID: "list-oidc-role-mappings", + Method: http.MethodGet, + Path: "/oidc/role-mappings", + Summary: "List OIDC group → role mappings", + Description: "Returns every mapping. On each OIDC login the user's group claim is matched against ClaimValue and matching rows become source='oidc' role assignments.", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.ListOidcRoleMappings) + + huma.Register(api, huma.Operation{ + OperationID: "create-oidc-role-mapping", + Method: http.MethodPost, + Path: "/oidc/role-mappings", + Summary: "Create an OIDC role mapping", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.CreateOidcRoleMapping) + + huma.Register(api, huma.Operation{ + OperationID: "update-oidc-role-mapping", + Method: http.MethodPut, + Path: "/oidc/role-mappings/{id}", + Summary: "Update an OIDC role mapping", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.UpdateOidcRoleMapping) + + huma.Register(api, huma.Operation{ + OperationID: "delete-oidc-role-mapping", + Method: http.MethodDelete, + Path: "/oidc/role-mappings/{id}", + Summary: "Delete an OIDC role mapping", + Tags: []string{"OIDC"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.DeleteOidcRoleMapping) } // ============================================================================ @@ -299,6 +395,11 @@ func (h *OidcHandler) HandleOidcCallback(ctx context.Context, input *HandleOidcC setCookies = append(setCookies, cookie.BuildTokenCookieStringFor(maxAge, tokenPair.AccessToken, cookie.SecureCookieFromContext(ctx))) } + userDto, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) + } + return &HandleOidcCallbackOutput{ SetCookie: setCookies, Body: auth.OidcCallbackResponse{ @@ -306,14 +407,7 @@ func (h *OidcHandler) HandleOidcCallback(ctx context.Context, input *HandleOidcC Token: tokenPair.AccessToken, RefreshToken: tokenPair.RefreshToken, ExpiresAt: tokenPair.ExpiresAt, - User: user.User{ - ID: userModel.ID, - Username: userModel.Username, - DisplayName: userModel.DisplayName, - Email: userModel.Email, - Roles: userModel.Roles, - OidcSubjectId: userModel.OidcSubjectId, - }, + User: userDto, }, }, nil } @@ -381,6 +475,11 @@ func (h *OidcHandler) ExchangeDeviceToken(ctx context.Context, input *ExchangeDe tokenCookie := cookie.BuildTokenCookieStringFor(maxAge, tokenPair.AccessToken, cookie.SecureCookieFromContext(ctx)) + userDto, err := h.userService.ToUserResponseDto(ctx, *userModel) + if err != nil { + return nil, huma.Error500InternalServerError((&common.UserMappingError{Err: err}).Error()) + } + return &ExchangeDeviceTokenOutput{ SetCookie: []string{tokenCookie}, Body: auth.OidcDeviceTokenResponse{ @@ -388,14 +487,93 @@ func (h *OidcHandler) ExchangeDeviceToken(ctx context.Context, input *ExchangeDe Token: tokenPair.AccessToken, RefreshToken: tokenPair.RefreshToken, ExpiresAt: tokenPair.ExpiresAt, - User: user.User{ - ID: userModel.ID, - Username: userModel.Username, - DisplayName: userModel.DisplayName, - Email: userModel.Email, - Roles: userModel.Roles, - OidcSubjectId: userModel.OidcSubjectId, - }, + User: userDto, }, }, nil } + +// ============================================================================ +// OIDC Role Mapping Handlers +// ============================================================================ + +func (h *OidcHandler) ListOidcRoleMappings(ctx context.Context, _ *ListOidcRoleMappingsInput) (*ListOidcRoleMappingsOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + rows, err := h.roleService.ListOidcMappings(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list mappings: " + err.Error()) + } + out := &ListOidcRoleMappingsOutput{} + out.Body.Success = true + out.Body.Data = make([]roletypes.OidcRoleMapping, len(rows)) + for i := range rows { + out.Body.Data[i] = toOidcMappingDTO(&rows[i]) + } + return out, nil +} + +func (h *OidcHandler) CreateOidcRoleMapping(ctx context.Context, input *CreateOidcRoleMappingInput) (*CreateOidcRoleMappingOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + mapping, err := h.roleService.CreateOidcMapping(ctx, input.Body.ClaimValue, input.Body.RoleID, input.Body.EnvironmentID) + if err != nil { + return nil, huma.Error500InternalServerError("failed to create mapping: " + err.Error()) + } + out := &CreateOidcRoleMappingOutput{} + out.Body.Success = true + out.Body.Data = toOidcMappingDTO(mapping) + return out, nil +} + +func (h *OidcHandler) UpdateOidcRoleMapping(ctx context.Context, input *UpdateOidcRoleMappingInput) (*UpdateOidcRoleMappingOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + mapping, err := h.roleService.UpdateOidcMapping(ctx, input.ID, input.Body.ClaimValue, input.Body.RoleID, input.Body.EnvironmentID) + if err != nil { + if common.IsOidcMappingNotFoundError(err) { + return nil, huma.Error404NotFound("mapping not found") + } + if common.IsOidcMappingEnvManagedError(err) { + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to update mapping: " + err.Error()) + } + out := &UpdateOidcRoleMappingOutput{} + out.Body.Success = true + out.Body.Data = toOidcMappingDTO(mapping) + return out, nil +} + +func (h *OidcHandler) DeleteOidcRoleMapping(ctx context.Context, input *DeleteOidcRoleMappingInput) (*DeleteOidcRoleMappingOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if err := h.roleService.DeleteOidcMapping(ctx, input.ID); err != nil { + if common.IsOidcMappingNotFoundError(err) { + return nil, huma.Error404NotFound("mapping not found") + } + if common.IsOidcMappingEnvManagedError(err) { + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to delete mapping: " + err.Error()) + } + out := &DeleteOidcRoleMappingOutput{} + out.Body.Success = true + out.Body.Message = "mapping deleted" + return out, nil +} + +func toOidcMappingDTO(m *models.OidcRoleMapping) roletypes.OidcRoleMapping { + return roletypes.OidcRoleMapping{ + ID: m.ID, + ClaimValue: m.ClaimValue, + RoleID: m.RoleID, + EnvironmentID: m.EnvironmentID, + Source: m.Source, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} diff --git a/backend/api/handlers/ports.go b/backend/api/handlers/ports.go index 99894bae9f..06acd90f3d 100644 --- a/backend/api/handlers/ports.go +++ b/backend/api/handlers/ports.go @@ -6,7 +6,9 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" porttypes "github.com/getarcaneapp/arcane/types/port" @@ -45,6 +47,7 @@ func RegisterPorts(api huma.API, portSvc *services.PortService) { Summary: "List port mappings", Tags: []string{"Ports"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermContainersList), }, h.ListPorts) } @@ -61,7 +64,7 @@ func (h *PortHandler) ListPorts(ctx context.Context, input *ListPortsInput) (*Li Sort: strings.TrimSpace(input.Sort), Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, diff --git a/backend/api/handlers/projects.go b/backend/api/handlers/projects.go index bebb564912..d302782b3b 100644 --- a/backend/api/handlers/projects.go +++ b/backend/api/handlers/projects.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "net/http" "time" @@ -11,10 +12,14 @@ import ( "github.com/danielgtaylor/huma/v2" humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" - projects "github.com/getarcaneapp/arcane/backend/pkg/projects" + "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/project" @@ -22,7 +27,9 @@ import ( // ProjectHandler provides Huma-based project management endpoints. type ProjectHandler struct { - projectService *services.ProjectService + projectService *services.ProjectService + activityService *services.ActivityService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -201,9 +208,13 @@ type PullProgressEvent struct { // RegisterProjects registers project management routes using Huma. // Note: WebSocket and streaming endpoints remain as Gin handlers. -func RegisterProjects(api huma.API, projectService *services.ProjectService) { +// +//nolint:maintidx // long but flat Huma route-registration function; complexity is sequential, not branching +func RegisterProjects(api huma.API, projectService *services.ProjectService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &ProjectHandler{ - projectService: projectService, + projectService: projectService, + activityService: activityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -217,6 +228,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsList), }, h.ListProjects) huma.Register(api, huma.Operation{ @@ -230,6 +242,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsList), }, h.GetProjectStatusCounts) huma.Register(api, huma.Operation{ @@ -243,7 +256,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.DeployProject) huma.Register(api, huma.Operation{ @@ -257,7 +270,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDown), }, h.DownProject) huma.Register(api, huma.Operation{ @@ -271,7 +284,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsCreate), }, h.CreateProject) huma.Register(api, huma.Operation{ @@ -285,6 +298,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProject) huma.Register(api, huma.Operation{ @@ -298,6 +312,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectCompose) huma.Register(api, huma.Operation{ @@ -311,6 +326,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectFiles) huma.Register(api, huma.Operation{ @@ -324,6 +340,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectRuntime) huma.Register(api, huma.Operation{ @@ -337,6 +354,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectUpdates) huma.Register(api, huma.Operation{ @@ -350,6 +368,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRead), }, h.GetProjectFile) huma.Register(api, huma.Operation{ @@ -363,7 +382,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.RedeployProject) huma.Register(api, huma.Operation{ @@ -377,7 +396,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDelete), }, h.DestroyProject) huma.Register(api, huma.Operation{ @@ -391,7 +410,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsUpdate), }, h.UpdateProject) huma.Register(api, huma.Operation{ @@ -405,7 +424,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsUpdate), }, h.UpdateProjectInclude) huma.Register(api, huma.Operation{ @@ -419,7 +438,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsRestart), }, h.RestartProject) huma.Register(api, huma.Operation{ @@ -433,7 +452,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsArchive), }, h.ArchiveProject) huma.Register(api, huma.Operation{ @@ -447,7 +466,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsArchive), }, h.UnarchiveProject) huma.Register(api, huma.Operation{ @@ -461,7 +480,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.PullProjectImages) huma.Register(api, huma.Operation{ @@ -475,7 +494,7 @@ func RegisterProjects(api huma.API, projectService *services.ProjectService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermProjectsDeploy), }, h.BuildProjectImages) } @@ -504,7 +523,7 @@ func (h *ProjectHandler) ListProjects(ctx context.Context, input *ListProjectsIn Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -553,10 +572,10 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP Body: base.ApiResponse[project.StatusCounts]{ Success: true, Data: project.StatusCounts{ - RunningProjects: int(running), - StoppedProjects: int(stopped), - TotalProjects: int(total), - ArchivedProjects: int(archived), + RunningProjects: running, + StoppedProjects: stopped, + TotalProjects: total, + ArchivedProjects: archived, }, }, }, nil @@ -564,9 +583,6 @@ func (h *ProjectHandler) GetProjectStatusCounts(ctx context.Context, input *GetP // DeployProject deploys a Docker Compose project. func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProjectInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -582,20 +598,38 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") - - writer := humaCtx.BodyWriter() + httpx.SetJSONStreamHeaders(humaCtx) + + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + rawWriter := humaCtx.BodyWriter() + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( + runtimeCtx, + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectDeploy, + "project", + input.ProjectID, + input.ProjectID, + user, + "Starting deployment", + "Project deployment started", + models.JSON{"projectID": input.ProjectID}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + if f, ok := rawWriter.(http.Flusher); ok { + f.Flush() + } + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Deploying project") _, _ = writer.Write([]byte(`{"type":"deploy","phase":"begin"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } - deployCtx := context.WithValue(humaCtx.Context(), projects.ProgressWriterKey{}, writer) + deployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, writer) if err := h.projectService.DeployProject(deployCtx, input.ProjectID, *user, input.Body); err != nil { + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project deployment failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -607,15 +641,13 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject if f, ok := writer.(http.Flusher); ok { f.Flush() } + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project deployment completed", nil) }, }, nil } // DownProject brings down a Docker Compose project. func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInput) (*DownProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -625,19 +657,27 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.DownProject(ctx, input.ProjectID, *user); err != nil { - var archivedErr *common.ProjectArchivedError - if errors.As(err, &archivedErr) { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Stopping project") + downCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) + if err := h.projectService.DownProject(downCtx, input.ProjectID, *user); err != nil { + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project stopped", err) + if _, ok := errors.AsType[*common.ProjectArchivedError](err); ok { return nil, huma.Error400BadRequest((&common.ProjectDownError{Err: err}).Error()) } return nil, huma.Error500InternalServerError((&common.ProjectDownError{Err: err}).Error()) } + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project stopped", nil) return &DownProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Project brought down successfully", + Message: "Project brought down successfully", + ActivityID: utils.StringPtrFromTrimmed(activityID), }, }, }, nil @@ -645,9 +685,6 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu // CreateProject creates a new Docker Compose project. func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProjectInput) (*CreateProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -657,7 +694,24 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - proj, err := h.projectService.CreateProject(ctx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + var proj *models.Project + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "project", + ResourceID: input.Body.Name, + ResourceName: input.Body.Name, + User: user, + Step: "Creating project", + Message: "Creating project", + SuccessMessage: "Project created successfully", + Metadata: models.JSON{"action": "create_project"}, + }, func(runtimeCtx context.Context) error { + var createErr error + proj, createErr = h.projectService.CreateProject(runtimeCtx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + return createErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectCreationError{Err: err}).Error()) } @@ -675,6 +729,7 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject response.GitOpsManagedBy = proj.GitOpsManagedBy response.IsArchived = proj.IsArchived response.ArchivedAt = proj.ArchivedAt + response.ActivityID = utils.StringPtrFromTrimmed(activityID) return &CreateProjectOutput{ Body: base.ApiResponse[project.CreateReponse]{ @@ -795,45 +850,18 @@ func (h *ProjectHandler) GetProjectFile(ctx context.Context, input *GetProjectFi // RedeployProject redeploys a Docker Compose project. func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployProjectInput) (*RedeployProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { + response, err := h.runProjectActivityActionResponseInternal(ctx, input.EnvironmentID, input.ProjectID, h.redeployProjectActivityConfigInternal()) + if err != nil { return nil, err } - if h.projectService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - - if input.ProjectID == "" { - return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error()) - } - - user, exists := humamw.GetCurrentUserFromContext(ctx) - if !exists { - return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) - } - - if err := h.projectService.RedeployProject(ctx, input.ProjectID, *user); err != nil { - var archivedErr *common.ProjectArchivedError - if errors.As(err, &archivedErr) { - return nil, huma.Error400BadRequest(err.Error()) - } - return nil, huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error()) - } return &RedeployProjectOutput{ - Body: base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{ - Message: "Project redeployed successfully", - }, - }, + Body: response, }, nil } // DestroyProject destroys a Docker Compose project. func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProjectInput) (*DestroyProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -857,15 +885,24 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje "projectID", input.ProjectID) } - if err := h.projectService.DestroyProject(ctx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Destroying project") + destroyCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) + if err := h.projectService.DestroyProject(destroyCtx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil { + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project destroyed", err) return nil, huma.Error500InternalServerError((&common.ProjectDestroyError{Err: err}).Error()) } + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project destroyed", nil) return &DestroyProjectOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Project destroyed successfully", + Message: "Project destroyed successfully", + ActivityID: utils.StringPtrFromTrimmed(activityID), }, }, }, nil @@ -873,9 +910,6 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje // UpdateProject updates a Docker Compose project. func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProjectInput) (*UpdateProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -889,14 +923,31 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if _, err := h.projectService.UpdateProject(ctx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "project", + ResourceID: input.ProjectID, + ResourceName: utils.DerefString(input.Body.Name), + User: user, + Step: "Updating project", + Message: "Updating project", + SuccessMessage: "Project updated successfully", + Metadata: models.JSON{"action": "update_project", "projectID": input.ProjectID}, + }, func(runtimeCtx context.Context) error { + _, updateErr := h.projectService.UpdateProject(runtimeCtx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user) + return updateErr + }) + if err != nil { return nil, huma.Error400BadRequest((&common.ProjectUpdateError{Err: err}).Error()) } - details, err := h.projectService.GetProjectDetails(ctx, input.ProjectID, project.AllDetails()) + details, err := h.projectService.GetProjectDetails(runtimeCtx, input.ProjectID, project.AllDetails()) if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectDetailsError{Err: err}).Error()) } + details.ActivityID = utils.StringPtrFromTrimmed(activityID) return &UpdateProjectOutput{ Body: base.ApiResponse[project.Details]{ @@ -908,9 +959,6 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject // UpdateProjectInclude updates an include file within a project. func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *UpdateProjectIncludeInput) (*UpdateProjectIncludeOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -924,14 +972,34 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.UpdateProjectIncludeFile(ctx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "project", + ResourceID: input.ProjectID, + ResourceName: input.ProjectID, + User: user, + Step: "Updating project file", + Message: "Updating project include file", + SuccessMessage: "Project file updated successfully", + Metadata: models.JSON{ + "action": "update_project_include", + "projectID": input.ProjectID, + "relativePath": input.Body.RelativePath, + }, + }, func(runtimeCtx context.Context) error { + return h.projectService.UpdateProjectIncludeFile(runtimeCtx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user) + }) + if err != nil { return nil, huma.Error400BadRequest((&common.ProjectUpdateError{Err: err}).Error()) } - details, err := h.projectService.GetProjectDetails(ctx, input.ProjectID, project.AllDetails()) + details, err := h.projectService.GetProjectDetails(runtimeCtx, input.ProjectID, project.AllDetails()) if err != nil { return nil, huma.Error500InternalServerError((&common.ProjectDetailsError{Err: err}).Error()) } + details.ActivityID = utils.StringPtrFromTrimmed(activityID) return &UpdateProjectIncludeOutput{ Body: base.ApiResponse[project.Details]{ @@ -943,44 +1011,123 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update // RestartProject restarts all containers in a project. func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProjectInput) (*RestartProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { + response, err := h.runProjectActivityActionResponseInternal(ctx, input.EnvironmentID, input.ProjectID, h.restartProjectActivityConfigInternal()) + if err != nil { return nil, err } + + return &RestartProjectOutput{ + Body: response, + }, nil +} + +type projectActivityActionConfigInternal struct { + ActivityType models.ActivityType + Step string + StartMessage string + WriterStep string + FailureMessage string + SuccessComplete string + SuccessMessage string + Action func(context.Context, string, models.User) error + Error func(error) error +} + +func (h *ProjectHandler) redeployProjectActivityConfigInternal() projectActivityActionConfigInternal { + return projectActivityActionConfigInternal{ + ActivityType: models.ActivityTypeProjectRedeploy, + Step: "Starting redeploy", + StartMessage: "Project redeploy started", + WriterStep: "Redeploying project", + FailureMessage: "Project redeploy failed", + SuccessComplete: "Project redeploy completed", + SuccessMessage: "Project redeployed successfully", + Action: func(runtimeCtx context.Context, projectID string, user models.User) error { + return h.projectService.RedeployProject(runtimeCtx, projectID, user) + }, + Error: projectArchivedActionErrorInternal(func(err error) error { + return huma.Error400BadRequest((&common.ProjectRedeploymentError{Err: err}).Error()) + }), + } +} + +func (h *ProjectHandler) restartProjectActivityConfigInternal() projectActivityActionConfigInternal { + return projectActivityActionConfigInternal{ + ActivityType: models.ActivityTypeProjectRestart, + Step: "Restarting project", + StartMessage: "Project restart requested", + WriterStep: "Restarting project", + FailureMessage: "Project restarted", + SuccessComplete: "Project restarted", + SuccessMessage: "Project restarted successfully", + Action: func(runtimeCtx context.Context, projectID string, user models.User) error { + return h.projectService.RestartProject(runtimeCtx, projectID, user) + }, + Error: projectArchivedActionErrorInternal(func(err error) error { + return huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error()) + }), + } +} + +func projectArchivedActionErrorInternal(fallback func(error) error) func(error) error { + return func(err error) error { + if _, ok := errors.AsType[*common.ProjectArchivedError](err); ok { + return huma.Error400BadRequest(err.Error()) + } + return fallback(err) + } +} + +func (h *ProjectHandler) runProjectActivityActionResponseInternal( + ctx context.Context, + environmentID string, + projectID string, + cfg projectActivityActionConfigInternal, +) (base.ApiResponse[base.MessageResponse], error) { + message, err := h.runProjectActivityActionInternal(ctx, environmentID, projectID, cfg) + if err != nil { + return base.ApiResponse[base.MessageResponse]{}, err + } + + return base.ApiResponse[base.MessageResponse]{ + Success: true, + Data: message, + }, nil +} + +func (h *ProjectHandler) runProjectActivityActionInternal(ctx context.Context, environmentID, projectID string, cfg projectActivityActionConfigInternal) (base.MessageResponse, error) { if h.projectService == nil { - return nil, huma.Error500InternalServerError("service not available") + return base.MessageResponse{}, huma.Error500InternalServerError("service not available") } - if input.ProjectID == "" { - return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error()) + if projectID == "" { + return base.MessageResponse{}, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error()) } user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { - return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) + return base.MessageResponse{}, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.projectService.RestartProject(ctx, input.ProjectID, *user); err != nil { - var archivedErr *common.ProjectArchivedError - if errors.As(err, &archivedErr) { - return nil, huma.Error400BadRequest(err.Error()) - } - return nil, huma.Error400BadRequest((&common.ProjectRestartError{Err: err}).Error()) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, environmentID, cfg.ActivityType, "project", projectID, projectID, user, cfg.Step, cfg.StartMessage, models.JSON{"projectID": projectID}) + activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, cfg.WriterStep) + actionCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter) + if err := cfg.Action(actionCtx, projectID, *user); err != nil { + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.FailureMessage, err) + return base.MessageResponse{}, cfg.Error(err) } + activitylib.FlushWriter(activityWriter) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, cfg.SuccessComplete, nil) - return &RestartProjectOutput{ - Body: base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{ - Message: "Project restarted successfully", - }, - }, + return base.MessageResponse{ + Message: cfg.SuccessMessage, + ActivityID: utils.StringPtrFromTrimmed(activityID), }, nil } func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProjectInput) (*ArchiveProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -995,8 +1142,7 @@ func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProje } if err := h.projectService.ArchiveProject(ctx, input.ProjectID, *user); err != nil { - var mustStopErr *common.ProjectMustBeStoppedError - if errors.As(err, &mustStopErr) { + if _, ok := errors.AsType[*common.ProjectMustBeStoppedError](err); ok { return nil, huma.Error400BadRequest(err.Error()) } return nil, huma.Error500InternalServerError((&common.ProjectArchiveError{Err: err}).Error()) @@ -1011,9 +1157,6 @@ func (h *ProjectHandler) ArchiveProject(ctx context.Context, input *ArchiveProje } func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveProjectInput) (*UnarchiveProjectOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1041,9 +1184,6 @@ func (h *ProjectHandler) UnarchiveProject(ctx context.Context, input *UnarchiveP // PullProjectImages pulls all images for a project with streaming progress. func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProjectImagesInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1059,19 +1199,34 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") - - writer := humaCtx.BodyWriter() - + httpx.SetJSONStreamHeaders(humaCtx) + + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + rawWriter := humaCtx.BodyWriter() + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( + runtimeCtx, + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectPull, + "project", + input.ProjectID, + input.ProjectID, + user, + "Pulling project images", + "Project image pull started", + models.JSON{"projectID": input.ProjectID}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Pulling project images") _, _ = writer.Write([]byte(`{"status":"starting project image pull"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } - if err := h.projectService.PullProjectImages(humaCtx.Context(), input.ProjectID, writer, *user, nil); err != nil { + if err := h.projectService.PullProjectImages(runtimeCtx, input.ProjectID, writer, *user, nil); err != nil { + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image pull failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -1083,15 +1238,13 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje if f, ok := writer.(http.Flusher); ok { f.Flush() } + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image pull completed", nil) }, }, nil } // BuildProjectImages builds compose services with build directives. func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildProjectInput) (*huma.StreamResponse, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.projectService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1115,18 +1268,34 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro return &huma.StreamResponse{ Body: func(humaCtx huma.Context) { //nolint:contextcheck // context is obtained from humaCtx.Context() - humaCtx.SetHeader("Content-Type", "application/x-json-stream") - humaCtx.SetHeader("Cache-Control", "no-cache") - humaCtx.SetHeader("Connection", "keep-alive") - humaCtx.SetHeader("X-Accel-Buffering", "no") - - writer := humaCtx.BodyWriter() + httpx.SetJSONStreamHeaders(humaCtx) + + runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx) + rawWriter := humaCtx.BodyWriter() + activityID, runtimeCtx := activitylib.StartHandlerActivityForUser( + runtimeCtx, + h.activityService, + input.EnvironmentID, + models.ActivityTypeProjectBuild, + "project", + input.ProjectID, + input.ProjectID, + user, + "Building project images", + "Project image build started", + models.JSON{"projectID": input.ProjectID, "services": options.Services}, + ) + activitylib.WriteStartedLine(rawWriter, activityID) + + writer := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, rawWriter, "Building project images") _, _ = writer.Write([]byte(`{"type":"build","phase":"begin"}` + "\n")) if f, ok := writer.(http.Flusher); ok { f.Flush() } - if err := h.projectService.BuildProjectServices(humaCtx.Context(), input.ProjectID, options, writer, user); err != nil { + if err := h.projectService.BuildProjectServices(runtimeCtx, input.ProjectID, options, writer, user); err != nil { + activitylib.FlushWriter(writer) + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image build failed", err) _, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error()) if f, ok := writer.(http.Flusher); ok { f.Flush() @@ -1138,6 +1307,7 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro if f, ok := writer.(http.Flusher); ok { f.Flush() } + activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Project image build completed", nil) }, }, nil } diff --git a/backend/api/handlers/remenv_handlers_test.go b/backend/api/handlers/remenv_handlers_test.go index 6b796eeb13..c771c4816f 100644 --- a/backend/api/handlers/remenv_handlers_test.go +++ b/backend/api/handlers/remenv_handlers_test.go @@ -13,6 +13,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/env" "github.com/getarcaneapp/arcane/types/jobschedule" @@ -22,10 +23,10 @@ import ( "gorm.io/gorm" ) -// adminTestContextInternal returns a context with the admin flag set, suitable for -// unit-testing handlers that call checkAdminInternal directly. +// adminTestContextInternal returns a context with a sudo PermissionSet attached, +// suitable for unit-testing handlers that gate via RequirePermission middleware. func adminTestContextInternal() context.Context { - return context.WithValue(context.Background(), humamiddleware.ContextKeyUserIsAdmin, true) + return context.WithValue(context.Background(), humamiddleware.ContextKeyUserPermissions, authz.SudoPermissionSet()) } func setupRemoteHandlerEnvironmentServiceInternal(t *testing.T, server *httptest.Server) *services.EnvironmentService { diff --git a/backend/api/handlers/roles.go b/backend/api/handlers/roles.go new file mode 100644 index 0000000000..307c123eb6 --- /dev/null +++ b/backend/api/handlers/roles.go @@ -0,0 +1,421 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + humamw "github.com/getarcaneapp/arcane/backend/api/middleware" + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/types/base" + roletypes "github.com/getarcaneapp/arcane/types/role" +) + +type RoleHandler struct { + roleService *services.RoleService +} + +// ---------- I/O wrappers ---------- + +type RolePaginatedResponse struct { + Success bool `json:"success"` + Data []roletypes.Role `json:"data"` + Pagination base.PaginationResponse `json:"pagination"` +} + +type ListRolesInput struct { + Search string `query:"search" doc:"Search by role name or description"` + Sort string `query:"sort" doc:"Column to sort by"` + Order string `query:"order" default:"asc" doc:"Sort direction (asc or desc)"` + Start int `query:"start" default:"0" doc:"Start index for pagination"` + Limit int `query:"limit" default:"20" doc:"Items per page"` +} + +type ListRolesOutput struct { + Body RolePaginatedResponse +} + +type GetRoleInput struct { + ID string `path:"id" doc:"Role ID"` +} + +type GetRoleOutput struct { + Body base.ApiResponse[roletypes.Role] +} + +type CreateRoleInput struct { + Body roletypes.CreateRole +} + +type CreateRoleOutput struct { + Body base.ApiResponse[roletypes.Role] +} + +type UpdateRoleInput struct { + ID string `path:"id" doc:"Role ID"` + Body roletypes.UpdateRole +} + +type UpdateRoleOutput struct { + Body base.ApiResponse[roletypes.Role] +} + +type DeleteRoleInput struct { + ID string `path:"id" doc:"Role ID"` +} + +type DeleteRoleOutput struct { + Body base.ApiResponse[base.MessageResponse] +} + +type PermissionsManifestOutput struct { + Body base.ApiResponse[roletypes.PermissionsManifest] +} + +type ListUserRoleAssignmentsInput struct { + UserID string `path:"userId" doc:"User ID"` +} + +type ListUserRoleAssignmentsOutput struct { + Body base.ApiResponse[[]roletypes.RoleAssignment] +} + +type SetUserRoleAssignmentsInput struct { + UserID string `path:"userId" doc:"User ID"` + Body roletypes.SetUserAssignments +} + +type SetUserRoleAssignmentsOutput struct { + Body base.ApiResponse[[]roletypes.RoleAssignment] +} + +// ---------- Registration ---------- + +func RegisterRoles(api huma.API, roleService *services.RoleService) { + h := &RoleHandler{roleService: roleService} + + huma.Register(api, huma.Operation{ + OperationID: "list-roles", + Method: http.MethodGet, + Path: "/roles", + Summary: "List roles", + Description: "Get a paginated list of roles (built-in + custom)", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermRolesList), + }, h.ListRoles) + + huma.Register(api, huma.Operation{ + OperationID: "get-role", + Method: http.MethodGet, + Path: "/roles/{id}", + Summary: "Get a role", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermRolesRead), + }, h.GetRole) + + huma.Register(api, huma.Operation{ + OperationID: "create-role", + Method: http.MethodPost, + Path: "/roles", + Summary: "Create a custom role", + Description: "Built-in roles cannot be created via this endpoint; only custom roles are accepted. Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.CreateRole) + + huma.Register(api, huma.Operation{ + OperationID: "update-role", + Method: http.MethodPut, + Path: "/roles/{id}", + Summary: "Update a custom role", + Description: "Built-in roles are read-only and return 403 on update. Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.UpdateRole) + + huma.Register(api, huma.Operation{ + OperationID: "delete-role", + Method: http.MethodDelete, + Path: "/roles/{id}", + Summary: "Delete a custom role", + Description: "Built-in roles are protected; deleting cascades all user assignments. Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.DeleteRole) + + huma.Register(api, huma.Operation{ + OperationID: "get-permissions-manifest", + Method: http.MethodGet, + Path: "/roles/available-permissions", + Summary: "Get the permission manifest", + Description: "Returns every permission the server recognizes, grouped by resource. Used by the role editor UI.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequirePermission(api, authz.PermRolesRead), + }, h.GetPermissionsManifest) + + huma.Register(api, huma.Operation{ + OperationID: "list-user-role-assignments", + Method: http.MethodGet, + Path: "/users/{userId}/role-assignments", + Summary: "List a user's role assignments", + Description: "Reserved for global admins.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.ListUserRoleAssignments) + + huma.Register(api, huma.Operation{ + OperationID: "set-user-role-assignments", + Method: http.MethodPut, + Path: "/users/{userId}/role-assignments", + Summary: "Replace a user's manual role assignments", + Description: "Replaces every source='manual' assignment for the user. source='oidc' assignments are not touched. Reserved for global admins; enforces the last-admin guard.", + Tags: []string{"Roles"}, + Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, + Middlewares: humamw.RequireGlobalAdmin(api), + }, h.SetUserRoleAssignments) +} + +// ---------- Handler implementations ---------- + +func (h *RoleHandler) ListRoles(ctx context.Context, input *ListRolesInput) (*ListRolesOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) + roles, paginationResp, err := h.roleService.ListRoles(ctx, params) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list roles: " + err.Error()) + } + dtos := make([]roletypes.Role, len(roles)) + for i := range roles { + dtos[i] = h.toRoleDTO(ctx, &roles[i]) + } + return &ListRolesOutput{ + Body: RolePaginatedResponse{ + Success: true, + Data: dtos, + Pagination: base.PaginationResponse{ + TotalPages: paginationResp.TotalPages, + TotalItems: paginationResp.TotalItems, + CurrentPage: paginationResp.CurrentPage, + ItemsPerPage: paginationResp.ItemsPerPage, + GrandTotalItems: paginationResp.GrandTotalItems, + }, + }, + }, nil +} + +func (h *RoleHandler) GetRole(ctx context.Context, input *GetRoleInput) (*GetRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + role, err := h.roleService.GetRole(ctx, input.ID) + if err != nil { + if common.IsRoleNotFoundError(err) { + return nil, huma.Error404NotFound("role not found") + } + return nil, huma.Error500InternalServerError("failed to get role: " + err.Error()) + } + return &GetRoleOutput{ + Body: base.ApiResponse[roletypes.Role]{Success: true, Data: h.toRoleDTO(ctx, role)}, + }, nil +} + +func (h *RoleHandler) CreateRole(ctx context.Context, input *CreateRoleInput) (*CreateRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + callerPS, _ := humamw.PermissionsFromContext(ctx) + if err := h.roleService.ValidatePermissionsAgainstCaller(callerPS, input.Body.Permissions); err != nil { + switch { + case common.IsUnknownPermissionError(err): + return nil, huma.Error400BadRequest(err.Error()) + case common.IsRolePermissionEscalationError(err): + return nil, huma.Error403Forbidden(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to validate role permissions: " + err.Error()) + } + role, err := h.roleService.CreateRole(ctx, input.Body.Name, input.Body.Description, input.Body.Permissions) + if err != nil { + if common.IsRoleNameTakenError(err) { + return nil, huma.Error409Conflict("role name already in use") + } + if common.IsUnknownPermissionError(err) { + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to create role: " + err.Error()) + } + return &CreateRoleOutput{ + Body: base.ApiResponse[roletypes.Role]{Success: true, Data: h.toRoleDTO(ctx, role)}, + }, nil +} + +func (h *RoleHandler) UpdateRole(ctx context.Context, input *UpdateRoleInput) (*UpdateRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + callerPS, _ := humamw.PermissionsFromContext(ctx) + if err := h.roleService.ValidatePermissionsAgainstCaller(callerPS, input.Body.Permissions); err != nil { + switch { + case common.IsUnknownPermissionError(err): + return nil, huma.Error400BadRequest(err.Error()) + case common.IsRolePermissionEscalationError(err): + return nil, huma.Error403Forbidden(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to validate role permissions: " + err.Error()) + } + role, err := h.roleService.UpdateRole(ctx, input.ID, input.Body.Name, input.Body.Description, input.Body.Permissions) + if err != nil { + switch { + case common.IsRoleNotFoundError(err): + return nil, huma.Error404NotFound("role not found") + case common.IsRoleBuiltInError(err): + return nil, huma.Error403Forbidden("built-in roles cannot be modified") + case common.IsRoleNameTakenError(err): + return nil, huma.Error409Conflict("role name already in use") + case common.IsUnknownPermissionError(err): + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to update role: " + err.Error()) + } + return &UpdateRoleOutput{ + Body: base.ApiResponse[roletypes.Role]{Success: true, Data: h.toRoleDTO(ctx, role)}, + }, nil +} + +func (h *RoleHandler) DeleteRole(ctx context.Context, input *DeleteRoleInput) (*DeleteRoleOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + if err := h.roleService.DeleteRole(ctx, input.ID); err != nil { + switch { + case common.IsRoleNotFoundError(err): + return nil, huma.Error404NotFound("role not found") + case common.IsRoleBuiltInError(err): + return nil, huma.Error403Forbidden("built-in roles cannot be deleted") + case common.IsNoGlobalAdminRemainsError(err): + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to delete role: " + err.Error()) + } + return &DeleteRoleOutput{ + Body: base.ApiResponse[base.MessageResponse]{Success: true, Data: base.MessageResponse{Message: "role deleted"}}, + }, nil +} + +func (h *RoleHandler) GetPermissionsManifest(_ context.Context, _ *struct{}) (*PermissionsManifestOutput, error) { + return &PermissionsManifestOutput{ + Body: base.ApiResponse[roletypes.PermissionsManifest]{Success: true, Data: buildPermissionsManifestInternal()}, + }, nil +} + +func (h *RoleHandler) ListUserRoleAssignments(ctx context.Context, input *ListUserRoleAssignmentsInput) (*ListUserRoleAssignmentsOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + rows, err := h.roleService.ListUserAssignments(ctx, input.UserID) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list assignments: " + err.Error()) + } + dtos := make([]roletypes.RoleAssignment, len(rows)) + for i := range rows { + dtos[i] = toAssignmentDTOInternal(&rows[i]) + } + return &ListUserRoleAssignmentsOutput{ + Body: base.ApiResponse[[]roletypes.RoleAssignment]{Success: true, Data: dtos}, + }, nil +} + +func (h *RoleHandler) SetUserRoleAssignments(ctx context.Context, input *SetUserRoleAssignmentsInput) (*SetUserRoleAssignmentsOutput, error) { + if h.roleService == nil { + return nil, huma.Error500InternalServerError("service not available") + } + desired := make([]models.UserRoleAssignment, len(input.Body.Assignments)) + for i, a := range input.Body.Assignments { + desired[i] = models.UserRoleAssignment{RoleID: a.RoleID, EnvironmentID: a.EnvironmentID} + } + if err := h.roleService.SetUserAssignments(ctx, input.UserID, desired); err != nil { + switch { + case common.IsInvalidRoleAssignmentError(err): + return nil, huma.Error400BadRequest(err.Error()) + case common.IsNoGlobalAdminRemainsError(err): + return nil, huma.Error409Conflict(err.Error()) + } + return nil, huma.Error500InternalServerError("failed to set assignments: " + err.Error()) + } + rows, err := h.roleService.ListUserAssignments(ctx, input.UserID) + if err != nil { + return nil, huma.Error500InternalServerError("failed to read back assignments: " + err.Error()) + } + dtos := make([]roletypes.RoleAssignment, len(rows)) + for i := range rows { + dtos[i] = toAssignmentDTOInternal(&rows[i]) + } + return &SetUserRoleAssignmentsOutput{ + Body: base.ApiResponse[[]roletypes.RoleAssignment]{Success: true, Data: dtos}, + }, nil +} + +// ---------- DTO mappers ---------- + +func (h *RoleHandler) toRoleDTO(ctx context.Context, r *models.Role) roletypes.Role { + out := roletypes.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Permissions: []string(r.Permissions), + BuiltIn: r.BuiltIn, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if count, err := h.roleService.CountUsersAssignedToRole(ctx, r.ID); err == nil { + out.AssignedUserCount = count + } + return out +} + +func toAssignmentDTOInternal(r *models.UserRoleAssignment) roletypes.RoleAssignment { + return roletypes.RoleAssignment{ + ID: r.ID, + UserID: r.UserID, + RoleID: r.RoleID, + EnvironmentID: r.EnvironmentID, + Source: r.Source, + CreatedAt: r.CreatedAt, + } +} + +// buildPermissionsManifestInternal maps the authz-owned permission catalog into +// the public API manifest shape used by the frontend role editor. +func buildPermissionsManifestInternal() roletypes.PermissionsManifest { + catalog := authz.PermissionCatalog() + resources := make([]roletypes.PermissionResource, len(catalog)) + for i, resource := range catalog { + actions := make([]roletypes.PermissionAction, len(resource.Actions)) + for j, action := range resource.Actions { + actions[j] = roletypes.PermissionAction{ + Key: action.Key, + Permission: action.Permission, + Label: action.Label, + Description: action.Description, + } + } + resources[i] = roletypes.PermissionResource{ + Key: resource.Key, + Label: resource.Label, + Scope: resource.Scope, + Actions: actions, + } + } + return roletypes.PermissionsManifest{Resources: resources} +} diff --git a/backend/api/handlers/settings.go b/backend/api/handlers/settings.go index 804792562f..bfe5e76eb1 100644 --- a/backend/api/handlers/settings.go +++ b/backend/api/handlers/settings.go @@ -15,6 +15,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" @@ -141,6 +142,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead), }, h.GetSettings) huma.Register(api, huma.Operation{ @@ -154,7 +156,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSettingsWrite), }, h.UpdateSettings) // Top-level settings endpoints (not environment-scoped) @@ -169,7 +171,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead), }, h.Search) huma.Register(api, huma.Operation{ @@ -183,7 +185,7 @@ func RegisterSettings(api huma.API, settingsService *services.SettingsService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSettingsRead), }, h.GetCategories) } @@ -264,7 +266,8 @@ func (h *SettingsHandler) GetSettings(ctx context.Context, input *GetSettingsInp return nil, huma.Error500InternalServerError("service not available") } - isAdmin := humamw.IsAdminFromContext(ctx) + ps, _ := humamw.PermissionsFromContext(ctx) + isAdmin := ps.IsGlobalAdmin() if input.EnvironmentID != "0" { if h.environmentService == nil { @@ -297,10 +300,6 @@ func (h *SettingsHandler) UpdateSettings(ctx context.Context, input *UpdateSetti return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.validateSettingsUpdateInput(input.Body); err != nil { return nil, err } @@ -313,7 +312,6 @@ func (h *SettingsHandler) UpdateSettings(ctx context.Context, input *UpdateSetti } func (h *SettingsHandler) validateSettingsUpdateInput(input settings.Update) error { - // Validate projects directory if provided and changed from current value. // Skip validation when the value matches the current (possibly env-overridden) setting // so that saving unrelated settings doesn't fail due to env-provided directory formats. @@ -399,12 +397,13 @@ func (h *SettingsHandler) updateSettingsForLocalEnvironment(ctx context.Context, func hasAuthSettingsUpdateInternal(req settings.Update) bool { return req.AuthLocalEnabled != nil || req.OidcEnabled != nil || req.AuthSessionTimeout != nil || req.AuthPasswordPolicy != nil || - req.AuthOidcConfig != nil || req.OidcClientId != nil || + req.OidcClientId != nil || req.OidcClientSecret != nil || req.OidcIssuerUrl != nil || - req.OidcScopes != nil || req.OidcAdminClaim != nil || - req.OidcAdminValue != nil || req.OidcMergeAccounts != nil || + req.OidcScopes != nil || + req.OidcMergeAccounts != nil || req.OidcSkipTlsVerify != nil || req.OidcAutoRedirectToProvider != nil || - req.OidcProviderName != nil || req.OidcProviderLogoUrl != nil + req.OidcProviderName != nil || req.OidcProviderLogoUrl != nil || + req.OidcGroupsClaim != nil } // Search searches settings by query. @@ -413,10 +412,6 @@ func (h *SettingsHandler) Search(ctx context.Context, input *SearchSettingsInput return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if strings.TrimSpace(input.Body.Query) == "" { return nil, huma.Error400BadRequest((&common.QueryParameterRequiredError{}).Error()) } @@ -431,10 +426,6 @@ func (h *SettingsHandler) GetCategories(ctx context.Context, input *struct{}) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - categories := h.settingsSearchService.GetSettingsCategories() return &GetCategoriesOutput{Body: categories}, nil } diff --git a/backend/api/handlers/settings_test.go b/backend/api/handlers/settings_test.go index 89fa455f23..3b1cc145d1 100644 --- a/backend/api/handlers/settings_test.go +++ b/backend/api/handlers/settings_test.go @@ -87,8 +87,7 @@ func TestSettingsHandler_UpdateLocalEnvironment_RejectsUnreadableProjectsDirecto handler := &SettingsHandler{settingsService: settingsSvc, cfg: &config.Config{}} - dirPtr := unreadable - _, err = handler.updateSettingsForLocalEnvironment(ctx, apitypes.Update{ProjectsDirectory: &dirPtr}) + _, err = handler.updateSettingsForLocalEnvironment(ctx, apitypes.Update{ProjectsDirectory: new(unreadable)}) require.Error(t, err) var statusErr huma.StatusError diff --git a/backend/api/handlers/swarm.go b/backend/api/handlers/swarm.go index 3778d34350..adbd92f908 100644 --- a/backend/api/handlers/swarm.go +++ b/backend/api/handlers/swarm.go @@ -14,6 +14,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/base" @@ -148,6 +149,7 @@ type GetSwarmNodeAgentDeploymentInput struct { type SwarmNodeAgentDeployment struct { DeploymentSnippet + EnvironmentID string `json:"environmentId"` Agent swarmtypes.NodeAgentStatus `json:"agent"` } @@ -518,59 +520,59 @@ func RegisterSwarm(api huma.API, swarmSvc *services.SwarmService, environmentSvc cfg: cfg, } - huma.Register(api, huma.Operation{OperationID: "list-swarm-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/services", Summary: "List swarm services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListServices) - huma.Register(api, huma.Operation{OperationID: "get-swarm-service", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Get swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetService) - huma.Register(api, huma.Operation{OperationID: "create-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services", Summary: "Create swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateService) - huma.Register(api, huma.Operation{OperationID: "update-swarm-service", Method: http.MethodPut, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Update swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateService) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-service", Method: http.MethodDelete, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Delete swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteService) - huma.Register(api, huma.Operation{OperationID: "list-swarm-service-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}/tasks", Summary: "List tasks for a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListServiceTasks) - huma.Register(api, huma.Operation{OperationID: "rollback-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/rollback", Summary: "Rollback a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.RollbackService) - huma.Register(api, huma.Operation{OperationID: "scale-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/scale", Summary: "Scale a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.ScaleService) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-nodes", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes", Summary: "List swarm nodes", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListNodes) - huma.Register(api, huma.Operation{OperationID: "get-swarm-node", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Get swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNode) - huma.Register(api, huma.Operation{OperationID: "get-swarm-node-agent-deployment", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/agent/deployment", Summary: "Get swarm node agent deployment snippets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetNodeAgentDeployment) - huma.Register(api, huma.Operation{OperationID: "update-swarm-node", Method: http.MethodPatch, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Update swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateNode) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-node", Method: http.MethodDelete, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Delete swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteNode) - huma.Register(api, huma.Operation{OperationID: "promote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/promote", Summary: "Promote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.PromoteNode) - huma.Register(api, huma.Operation{OperationID: "demote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/demote", Summary: "Demote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DemoteNode) - huma.Register(api, huma.Operation{OperationID: "list-swarm-node-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}/tasks", Summary: "List tasks for a swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListNodeTasks) - huma.Register(api, huma.Operation{OperationID: "get-swarm-node-identity", Method: http.MethodGet, Path: "/swarm/node-identity", Summary: "Get local swarm node identity", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetNodeIdentity) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/tasks", Summary: "List swarm tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListTasks) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-stacks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks", Summary: "List swarm stacks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStacks) - huma.Register(api, huma.Operation{OperationID: "deploy-swarm-stack", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks", Summary: "Deploy swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeployStack) - huma.Register(api, huma.Operation{OperationID: "get-swarm-stack", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Get swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetStack) - huma.Register(api, huma.Operation{OperationID: "get-swarm-stack-source", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Get swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetStackSource) - huma.Register(api, huma.Operation{OperationID: "update-swarm-stack-source", Method: http.MethodPut, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Update swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateStackSource) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-stack", Method: http.MethodDelete, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Delete swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteStack) - huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/services", Summary: "List swarm stack services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStackServices) - huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/tasks", Summary: "List swarm stack tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListStackTasks) - huma.Register(api, huma.Operation{OperationID: "render-swarm-stack-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks/config/render", Summary: "Render/validate swarm stack config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.RenderStackConfig) - - huma.Register(api, huma.Operation{OperationID: "get-swarm-status", Method: http.MethodGet, Path: "/environments/{id}/swarm/status", Summary: "Get swarm status", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSwarmStatus) - huma.Register(api, huma.Operation{OperationID: "get-swarm-info", Method: http.MethodGet, Path: "/environments/{id}/swarm/info", Summary: "Get swarm info", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSwarmInfo) - huma.Register(api, huma.Operation{OperationID: "init-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/init", Summary: "Initialize swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.InitSwarm) - huma.Register(api, huma.Operation{OperationID: "join-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/join", Summary: "Join swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.JoinSwarm) - huma.Register(api, huma.Operation{OperationID: "leave-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/leave", Summary: "Leave swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.LeaveSwarm) - huma.Register(api, huma.Operation{OperationID: "unlock-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/unlock", Summary: "Unlock swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UnlockSwarm) - huma.Register(api, huma.Operation{OperationID: "get-swarm-unlock-key", Method: http.MethodGet, Path: "/environments/{id}/swarm/unlock-key", Summary: "Get swarm unlock key", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetUnlockKey) - huma.Register(api, huma.Operation{OperationID: "get-swarm-join-tokens", Method: http.MethodGet, Path: "/environments/{id}/swarm/join-tokens", Summary: "Get swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.GetJoinTokens) - huma.Register(api, huma.Operation{OperationID: "rotate-swarm-join-tokens", Method: http.MethodPost, Path: "/environments/{id}/swarm/join-tokens/rotate", Summary: "Rotate swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.RotateJoinTokens) - huma.Register(api, huma.Operation{OperationID: "update-swarm-spec", Method: http.MethodPut, Path: "/environments/{id}/swarm/spec", Summary: "Update swarm spec", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateSwarmSpec) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-configs", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs", Summary: "List swarm configs", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListConfigs) - huma.Register(api, huma.Operation{OperationID: "get-swarm-config", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Get swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetConfig) - huma.Register(api, huma.Operation{OperationID: "create-swarm-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/configs", Summary: "Create swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateConfig) - huma.Register(api, huma.Operation{OperationID: "update-swarm-config", Method: http.MethodPut, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Update swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateConfig) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-config", Method: http.MethodDelete, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Delete swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteConfig) - - huma.Register(api, huma.Operation{OperationID: "list-swarm-secrets", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets", Summary: "List swarm secrets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.ListSecrets) - huma.Register(api, huma.Operation{OperationID: "get-swarm-secret", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Get swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}}, h.GetSecret) - huma.Register(api, huma.Operation{OperationID: "create-swarm-secret", Method: http.MethodPost, Path: "/environments/{id}/swarm/secrets", Summary: "Create swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.CreateSecret) - huma.Register(api, huma.Operation{OperationID: "update-swarm-secret", Method: http.MethodPut, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Update swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.UpdateSecret) - huma.Register(api, huma.Operation{OperationID: "delete-swarm-secret", Method: http.MethodDelete, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Delete swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequireAdmin(api)}, h.DeleteSecret) + huma.Register(api, huma.Operation{OperationID: "list-swarm-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/services", Summary: "List swarm services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListServices) + huma.Register(api, huma.Operation{OperationID: "get-swarm-service", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Get swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetService) + huma.Register(api, huma.Operation{OperationID: "create-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services", Summary: "Create swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.CreateService) + huma.Register(api, huma.Operation{OperationID: "update-swarm-service", Method: http.MethodPut, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Update swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.UpdateService) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-service", Method: http.MethodDelete, Path: "/environments/{id}/swarm/services/{serviceId}", Summary: "Delete swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.DeleteService) + huma.Register(api, huma.Operation{OperationID: "list-swarm-service-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/services/{serviceId}/tasks", Summary: "List tasks for a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListServiceTasks) + huma.Register(api, huma.Operation{OperationID: "rollback-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/rollback", Summary: "Rollback a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.RollbackService) + huma.Register(api, huma.Operation{OperationID: "scale-swarm-service", Method: http.MethodPost, Path: "/environments/{id}/swarm/services/{serviceId}/scale", Summary: "Scale a swarm service", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmServices)}, h.ScaleService) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-nodes", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes", Summary: "List swarm nodes", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListNodes) + huma.Register(api, huma.Operation{OperationID: "get-swarm-node", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Get swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetNode) + huma.Register(api, huma.Operation{OperationID: "get-swarm-node-agent-deployment", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/agent/deployment", Summary: "Get swarm node agent deployment snippets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.GetNodeAgentDeployment) + huma.Register(api, huma.Operation{OperationID: "update-swarm-node", Method: http.MethodPatch, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Update swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.UpdateNode) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-node", Method: http.MethodDelete, Path: "/environments/{id}/swarm/nodes/{nodeId}", Summary: "Delete swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.DeleteNode) + huma.Register(api, huma.Operation{OperationID: "promote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/promote", Summary: "Promote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.PromoteNode) + huma.Register(api, huma.Operation{OperationID: "demote-swarm-node", Method: http.MethodPost, Path: "/environments/{id}/swarm/nodes/{nodeId}/demote", Summary: "Demote swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmNodes)}, h.DemoteNode) + huma.Register(api, huma.Operation{OperationID: "list-swarm-node-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/nodes/{nodeId}/tasks", Summary: "List tasks for a swarm node", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListNodeTasks) + huma.Register(api, huma.Operation{OperationID: "get-swarm-node-identity", Method: http.MethodGet, Path: "/swarm/node-identity", Summary: "Get local swarm node identity", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetNodeIdentity) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/tasks", Summary: "List swarm tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListTasks) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-stacks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks", Summary: "List swarm stacks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListStacks) + huma.Register(api, huma.Operation{OperationID: "deploy-swarm-stack", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks", Summary: "Deploy swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.DeployStack) + huma.Register(api, huma.Operation{OperationID: "get-swarm-stack", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Get swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetStack) + huma.Register(api, huma.Operation{OperationID: "get-swarm-stack-source", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Get swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.GetStackSource) + huma.Register(api, huma.Operation{OperationID: "update-swarm-stack-source", Method: http.MethodPut, Path: "/environments/{id}/swarm/stacks/{name}/source", Summary: "Update swarm stack source", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.UpdateStackSource) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-stack", Method: http.MethodDelete, Path: "/environments/{id}/swarm/stacks/{name}", Summary: "Delete swarm stack", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmStacks)}, h.DeleteStack) + huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-services", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/services", Summary: "List swarm stack services", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListStackServices) + huma.Register(api, huma.Operation{OperationID: "list-swarm-stack-tasks", Method: http.MethodGet, Path: "/environments/{id}/swarm/stacks/{name}/tasks", Summary: "List swarm stack tasks", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListStackTasks) + huma.Register(api, huma.Operation{OperationID: "render-swarm-stack-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/stacks/config/render", Summary: "Render/validate swarm stack config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.RenderStackConfig) + + huma.Register(api, huma.Operation{OperationID: "get-swarm-status", Method: http.MethodGet, Path: "/environments/{id}/swarm/status", Summary: "Get swarm status", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetSwarmStatus) + huma.Register(api, huma.Operation{OperationID: "get-swarm-info", Method: http.MethodGet, Path: "/environments/{id}/swarm/info", Summary: "Get swarm info", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetSwarmInfo) + huma.Register(api, huma.Operation{OperationID: "init-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/init", Summary: "Initialize swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmInit)}, h.InitSwarm) + huma.Register(api, huma.Operation{OperationID: "join-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/join", Summary: "Join swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmJoin)}, h.JoinSwarm) + huma.Register(api, huma.Operation{OperationID: "leave-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/leave", Summary: "Leave swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmLeave)}, h.LeaveSwarm) + huma.Register(api, huma.Operation{OperationID: "unlock-swarm", Method: http.MethodPost, Path: "/environments/{id}/swarm/unlock", Summary: "Unlock swarm", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.UnlockSwarm) + huma.Register(api, huma.Operation{OperationID: "get-swarm-unlock-key", Method: http.MethodGet, Path: "/environments/{id}/swarm/unlock-key", Summary: "Get swarm unlock key", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.GetUnlockKey) + huma.Register(api, huma.Operation{OperationID: "get-swarm-join-tokens", Method: http.MethodGet, Path: "/environments/{id}/swarm/join-tokens", Summary: "Get swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.GetJoinTokens) + huma.Register(api, huma.Operation{OperationID: "rotate-swarm-join-tokens", Method: http.MethodPost, Path: "/environments/{id}/swarm/join-tokens/rotate", Summary: "Rotate swarm join tokens", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmUnlock)}, h.RotateJoinTokens) + huma.Register(api, huma.Operation{OperationID: "update-swarm-spec", Method: http.MethodPut, Path: "/environments/{id}/swarm/spec", Summary: "Update swarm spec", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSpec)}, h.UpdateSwarmSpec) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-configs", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs", Summary: "List swarm configs", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListConfigs) + huma.Register(api, huma.Operation{OperationID: "get-swarm-config", Method: http.MethodGet, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Get swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetConfig) + huma.Register(api, huma.Operation{OperationID: "create-swarm-config", Method: http.MethodPost, Path: "/environments/{id}/swarm/configs", Summary: "Create swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmConfigs)}, h.CreateConfig) + huma.Register(api, huma.Operation{OperationID: "update-swarm-config", Method: http.MethodPut, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Update swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmConfigs)}, h.UpdateConfig) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-config", Method: http.MethodDelete, Path: "/environments/{id}/swarm/configs/{configId}", Summary: "Delete swarm config", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmConfigs)}, h.DeleteConfig) + + huma.Register(api, huma.Operation{OperationID: "list-swarm-secrets", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets", Summary: "List swarm secrets", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.ListSecrets) + huma.Register(api, huma.Operation{OperationID: "get-swarm-secret", Method: http.MethodGet, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Get swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmRead)}, h.GetSecret) + huma.Register(api, huma.Operation{OperationID: "create-swarm-secret", Method: http.MethodPost, Path: "/environments/{id}/swarm/secrets", Summary: "Create swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSecrets)}, h.CreateSecret) + huma.Register(api, huma.Operation{OperationID: "update-swarm-secret", Method: http.MethodPut, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Update swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSecrets)}, h.UpdateSecret) + huma.Register(api, huma.Operation{OperationID: "delete-swarm-secret", Method: http.MethodDelete, Path: "/environments/{id}/swarm/secrets/{secretId}", Summary: "Delete swarm secret", Tags: []string{"Swarm"}, Security: []map[string][]string{{"BearerAuth": {}}, {"ApiKeyAuth": {}}}, Middlewares: humamw.RequirePermission(api, authz.PermSwarmSecrets)}, h.DeleteSecret) } // ListServices lists swarm services for an environment and returns a paginated response. @@ -644,10 +646,6 @@ func (h *SwarmHandler) CreateService(ctx context.Context, input *CreateSwarmServ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.CreateService(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceCreateError{Err: err}).Error()) @@ -673,10 +671,6 @@ func (h *SwarmHandler) UpdateService(ctx context.Context, input *UpdateSwarmServ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.UpdateService(ctx, input.ServiceID, input.Body) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceUpdateError{Err: err}).Error()) @@ -703,10 +697,6 @@ func (h *SwarmHandler) DeleteService(ctx context.Context, input *DeleteSwarmServ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveService(ctx, input.ServiceID); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound((&common.SwarmServiceNotFoundError{Err: err}).Error()) @@ -761,10 +751,6 @@ func (h *SwarmHandler) RollbackService(ctx context.Context, input *RollbackSwarm if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.RollbackService(ctx, input.ServiceID) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceUpdateError{Err: err}).Error()) @@ -790,10 +776,6 @@ func (h *SwarmHandler) ScaleService(ctx context.Context, input *ScaleSwarmServic if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.ScaleService(ctx, input.ServiceID, input.Body.Replicas) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmServiceUpdateError{Err: err}).Error()) @@ -877,10 +859,6 @@ func (h *SwarmHandler) GetNodeAgentDeployment(ctx context.Context, input *GetSwa if h.swarmService == nil || h.environmentService == nil || h.cfg == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - node, err := h.swarmService.GetNode(ctx, input.EnvironmentID, input.NodeID) if err != nil { if errdefs.IsNotFound(err) { @@ -981,10 +959,6 @@ func (h *SwarmHandler) UpdateNode(ctx context.Context, input *UpdateSwarmNodeInp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateNode(ctx, input.NodeID, input.Body); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1009,10 +983,6 @@ func (h *SwarmHandler) DeleteNode(ctx context.Context, input *DeleteSwarmNodeInp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveNode(ctx, input.NodeID, input.Force); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1037,10 +1007,6 @@ func (h *SwarmHandler) PromoteNode(ctx context.Context, input *PromoteSwarmNodeI if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.PromoteNode(ctx, input.NodeID); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1065,10 +1031,6 @@ func (h *SwarmHandler) DemoteNode(ctx context.Context, input *DemoteSwarmNodeInp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.DemoteNode(ctx, input.NodeID); err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmNodeNotFoundError{Err: err}).Error()) } @@ -1175,10 +1137,6 @@ func (h *SwarmHandler) DeployStack(ctx context.Context, input *DeploySwarmStackI if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.DeployStack(ctx, input.EnvironmentID, input.Body) if err != nil { return nil, mapSwarmServiceError(err, (&common.SwarmStackDeployError{Err: err}).Error()) @@ -1208,7 +1166,7 @@ func (h *SwarmHandler) GetStack(ctx context.Context, input *GetSwarmStackInput) stack, err := h.swarmService.GetStack(ctx, input.EnvironmentID, input.Name) if err != nil { if errdefs.IsNotFound(err) { - return nil, huma.Error404NotFound(("Swarm stack not found")) + return nil, huma.Error404NotFound("Swarm stack not found") } return nil, mapSwarmServiceError(err, "Failed to inspect swarm stack") } @@ -1231,10 +1189,6 @@ func (h *SwarmHandler) GetStackSource(ctx context.Context, input *GetSwarmStackS if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - source, err := h.swarmService.GetStackSource(ctx, input.EnvironmentID, input.Name) if err != nil { if errdefs.IsNotFound(err) { @@ -1255,10 +1209,6 @@ func (h *SwarmHandler) UpdateStackSource(ctx context.Context, input *UpdateSwarm if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - source, err := h.swarmService.UpdateStackSource(ctx, input.EnvironmentID, input.Name, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm stack source") @@ -1285,10 +1235,6 @@ func (h *SwarmHandler) DeleteStack(ctx context.Context, input *DeleteSwarmStackI if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveStack(ctx, input.EnvironmentID, input.Name); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound("Swarm stack not found") @@ -1386,7 +1332,7 @@ func (h *SwarmHandler) RenderStackConfig(ctx context.Context, input *RenderSwarm return &RenderSwarmStackConfigOutput{Body: base.ApiResponse[swarmtypes.StackRenderConfigResponse]{Success: true, Data: *resp}}, nil } -// GetSwarmInfo returns the current swarm cluster metadata for an environment. +// GetSwarmStatus returns the current swarm cluster metadata for an environment. // // It delegates to the swarm service to inspect the local swarm state and maps // service-layer failures to the API's HTTP error model. @@ -1452,10 +1398,6 @@ func (h *SwarmHandler) InitSwarm(ctx context.Context, input *InitSwarmInput) (*I if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.InitSwarm(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to initialize swarm") @@ -1481,10 +1423,6 @@ func (h *SwarmHandler) JoinSwarm(ctx context.Context, input *JoinSwarmInput) (*J if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.JoinSwarm(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to join swarm") } @@ -1509,10 +1447,6 @@ func (h *SwarmHandler) LeaveSwarm(ctx context.Context, input *LeaveSwarmInput) ( if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.LeaveSwarm(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to leave swarm") } @@ -1537,10 +1471,6 @@ func (h *SwarmHandler) UnlockSwarm(ctx context.Context, input *UnlockSwarmInput) if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UnlockSwarm(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to unlock swarm") } @@ -1564,10 +1494,6 @@ func (h *SwarmHandler) GetUnlockKey(ctx context.Context, input *GetSwarmUnlockKe if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.GetSwarmUnlockKey(ctx) if err != nil { return nil, mapSwarmServiceError(err, "Failed to get swarm unlock key") @@ -1590,10 +1516,6 @@ func (h *SwarmHandler) GetJoinTokens(ctx context.Context, input *GetSwarmJoinTok if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - resp, err := h.swarmService.GetSwarmJoinTokens(ctx) if err != nil { return nil, mapSwarmServiceError(err, "Failed to get swarm join tokens") @@ -1617,10 +1539,6 @@ func (h *SwarmHandler) RotateJoinTokens(ctx context.Context, input *RotateSwarmJ if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RotateSwarmJoinTokens(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to rotate swarm join tokens") } @@ -1645,10 +1563,6 @@ func (h *SwarmHandler) UpdateSwarmSpec(ctx context.Context, input *UpdateSwarmSp if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateSwarmSpec(ctx, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm spec") } @@ -1726,10 +1640,6 @@ func (h *SwarmHandler) CreateConfig(ctx context.Context, input *CreateSwarmConfi if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - cfg, err := h.swarmService.CreateConfig(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to create swarm config") @@ -1756,10 +1666,6 @@ func (h *SwarmHandler) UpdateConfig(ctx context.Context, input *UpdateSwarmConfi if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateConfig(ctx, input.ConfigID, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm config") } @@ -1781,10 +1687,6 @@ func (h *SwarmHandler) DeleteConfig(ctx context.Context, input *DeleteSwarmConfi if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveConfig(ctx, input.ConfigID); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound("Swarm config not found") @@ -1865,10 +1767,6 @@ func (h *SwarmHandler) CreateSecret(ctx context.Context, input *CreateSwarmSecre if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - secret, err := h.swarmService.CreateSecret(ctx, input.Body) if err != nil { return nil, mapSwarmServiceError(err, "Failed to create swarm secret") @@ -1895,10 +1793,6 @@ func (h *SwarmHandler) UpdateSecret(ctx context.Context, input *UpdateSwarmSecre if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.UpdateSecret(ctx, input.SecretID, input.Body); err != nil { return nil, mapSwarmServiceError(err, "Failed to update swarm secret") } @@ -1920,10 +1814,6 @@ func (h *SwarmHandler) DeleteSecret(ctx context.Context, input *DeleteSwarmSecre if h.swarmService == nil { return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.swarmService.RemoveSecret(ctx, input.SecretID); err != nil { if errdefs.IsNotFound(err) { return nil, huma.Error404NotFound("Swarm secret not found") @@ -2046,7 +1936,7 @@ func buildSwarmQueryParams(search, sort, order string, start, limit int) paginat Sort: strings.TrimSpace(sort), Order: pagination.SortOrder(order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: start, Limit: limit, }, diff --git a/backend/api/handlers/system.go b/backend/api/handlers/system.go index 90bac028ff..15a65f21bf 100644 --- a/backend/api/handlers/system.go +++ b/backend/api/handlers/system.go @@ -11,7 +11,9 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" "github.com/getarcaneapp/arcane/types/dockerinfo" @@ -22,10 +24,12 @@ import ( // SystemHandler handles system management endpoints. type SystemHandler struct { - dockerService *services.DockerClientService - systemService *services.SystemService - upgradeService *services.SystemUpgradeService - cfg *config.Config + dockerService *services.DockerClientService + systemService *services.SystemService + upgradeService *services.SystemUpgradeService + activityService *services.ActivityService + cfg *config.Config + appCtx context.Context } // --- Input/Output Types --- @@ -113,12 +117,14 @@ type TriggerUpgradeOutput struct { // RegisterSystem registers system management endpoints using Huma. // Note: WebSocket endpoints (stats) remain in the Gin handler. -func RegisterSystem(api huma.API, dockerService *services.DockerClientService, systemService *services.SystemService, upgradeService *services.SystemUpgradeService, cfg *config.Config) { +func RegisterSystem(api huma.API, dockerService *services.DockerClientService, systemService *services.SystemService, upgradeService *services.SystemUpgradeService, cfg *config.Config, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &SystemHandler{ - dockerService: dockerService, - systemService: systemService, - upgradeService: upgradeService, - cfg: cfg, + dockerService: dockerService, + systemService: systemService, + upgradeService: upgradeService, + activityService: activityService, + cfg: cfg, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -146,6 +152,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermSystemRead), }, h.GetDockerInfo) huma.Register(api, huma.Operation{ @@ -159,7 +166,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSystemPrune), }, h.PruneAll) huma.Register(api, huma.Operation{ @@ -173,7 +180,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStart), }, h.StartAllContainers) huma.Register(api, huma.Operation{ @@ -187,7 +194,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStart), }, h.StartAllStoppedContainers) huma.Register(api, huma.Operation{ @@ -201,7 +208,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermContainersStop), }, h.StopAllContainers) huma.Register(api, huma.Operation{ @@ -215,6 +222,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermContainersCreate), }, h.ConvertDockerRun) huma.Register(api, huma.Operation{ @@ -228,7 +236,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSystemRead), }, h.CheckUpgradeAvailable) huma.Register(api, huma.Operation{ @@ -243,7 +251,7 @@ func RegisterSystem(api huma.API, dockerService *services.DockerClientService, s {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermSystemUpgrade), }, h.TriggerUpgrade) } @@ -301,7 +309,7 @@ func (h *SystemHandler) GetDockerInfo(ctx context.Context, input *GetDockerInfoI if !docker.IsDockerContainer() { if cgroupLimits, err := docker.DetectCgroupLimits(); err == nil { if limit := cgroupLimits.MemoryLimit; limit > 0 { - limitInt := int64(limit) + limitInt := limit if memTotal == 0 || limitInt < memTotal { memTotal = limitInt } @@ -364,10 +372,6 @@ func (h *SystemHandler) PruneAll(ctx context.Context, input *PruneAllInput) (*Pr return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - slog.InfoContext(ctx, "System prune operation initiated", "containers", input.Body.Containers, "images", input.Body.Images, @@ -375,18 +379,10 @@ func (h *SystemHandler) PruneAll(ctx context.Context, input *PruneAllInput) (*Pr "networks", input.Body.Networks, "build_cache", input.Body.BuildCache) - result, err := h.systemService.PruneAll(ctx, input.Body) - if err != nil { - slog.ErrorContext(ctx, "System prune operation failed", "error", err) - return nil, huma.Error500InternalServerError((&common.SystemPruneError{Err: err}).Error()) - } + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result := h.systemService.StartPruneAll(runtimeCtx, input.EnvironmentID, input.Body) - slog.InfoContext(ctx, "System prune operation completed successfully", - "containers_pruned", len(result.ContainersPruned), - "images_deleted", len(result.ImagesDeleted), - "volumes_deleted", len(result.VolumesDeleted), - "networks_deleted", len(result.NetworksDeleted), - "space_reclaimed", result.SpaceReclaimed) + slog.InfoContext(runtimeCtx, "System prune background activity started", "activityId", result.ActivityID) return &PruneAllOutput{ Body: base.ApiResponse[system.PruneAllResult]{ @@ -402,11 +398,8 @@ func (h *SystemHandler) StartAllContainers(ctx context.Context, input *StartAllC return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - result, err := h.systemService.StartAllContainers(ctx) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.systemService.StartAllContainers(runtimeCtx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartAllError{Err: err}).Error()) } @@ -425,11 +418,8 @@ func (h *SystemHandler) StartAllStoppedContainers(ctx context.Context, input *St return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - result, err := h.systemService.StartAllStoppedContainers(ctx) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.systemService.StartAllStoppedContainers(runtimeCtx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStartStoppedError{Err: err}).Error()) } @@ -448,11 +438,8 @@ func (h *SystemHandler) StopAllContainers(ctx context.Context, input *StopAllCon return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - result, err := h.systemService.StopAllContainers(ctx) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.systemService.StopAllContainers(runtimeCtx, input.EnvironmentID) if err != nil { return nil, huma.Error500InternalServerError((&common.ContainerStopAllError{Err: err}).Error()) } @@ -497,10 +484,6 @@ func (h *SystemHandler) CheckUpgradeAvailable(ctx context.Context, input *CheckU return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - canUpgrade, err := h.upgradeService.CanUpgrade(ctx) if err != nil { slog.Debug("System upgrade check failed", "error", err) @@ -528,10 +511,6 @@ func (h *SystemHandler) TriggerUpgrade(ctx context.Context, input *TriggerUpgrad return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - user, exists := humamw.GetCurrentUserFromContext(ctx) if !exists { return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) diff --git a/backend/api/handlers/templates.go b/backend/api/handlers/templates.go index 66d26ed4a3..3122f8b421 100644 --- a/backend/api/handlers/templates.go +++ b/backend/api/handlers/templates.go @@ -11,6 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/env" @@ -192,7 +193,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.FetchRegistry) huma.Register(api, huma.Operation{ @@ -202,6 +203,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "List templates (paginated)", Description: "Get a paginated list of compose templates", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesList), }, h.ListTemplates) huma.Register(api, huma.Operation{ @@ -211,6 +213,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "List all templates", Description: "Get all compose templates without pagination", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesList), }, h.GetAllTemplates) huma.Register(api, huma.Operation{ @@ -220,6 +223,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "Get a template", Description: "Get a compose template by ID", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetTemplate) huma.Register(api, huma.Operation{ @@ -229,6 +233,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, Summary: "Get template content", Description: "Get the compose content for a template with parsed data", Tags: []string{"Templates"}, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetTemplateContent) // Protected endpoints @@ -243,6 +248,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesCreate), }, h.CreateTemplate) huma.Register(api, huma.Operation{ @@ -256,6 +262,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.UpdateTemplate) huma.Register(api, huma.Operation{ @@ -269,6 +276,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesDelete), }, h.DeleteTemplate) huma.Register(api, huma.Operation{ @@ -282,6 +290,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.DownloadTemplate) huma.Register(api, huma.Operation{ @@ -295,6 +304,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetDefaultTemplates) huma.Register(api, huma.Operation{ @@ -308,6 +318,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.SaveDefaultTemplates) huma.Register(api, huma.Operation{ @@ -321,6 +332,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesList), }, h.GetRegistries) huma.Register(api, huma.Operation{ @@ -334,7 +346,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesCreate), }, h.CreateRegistry) huma.Register(api, huma.Operation{ @@ -348,7 +360,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.UpdateRegistry) huma.Register(api, huma.Operation{ @@ -362,7 +374,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesDelete), }, h.DeleteRegistry) huma.Register(api, huma.Operation{ @@ -376,7 +388,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesRead), }, h.GetGlobalVariables) huma.Register(api, huma.Operation{ @@ -390,7 +402,7 @@ func RegisterTemplates(api huma.API, templateService *services.TemplateService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermTemplatesUpdate), }, h.UpdateGlobalVariables) } @@ -404,7 +416,7 @@ func (h *TemplateHandler) ListTemplates(ctx context.Context, input *ListTemplate return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } @@ -771,9 +783,6 @@ func (h *TemplateHandler) GetRegistries(ctx context.Context, _ *GetTemplateRegis // CreateRegistry creates a new template registry. func (h *TemplateHandler) CreateRegistry(ctx context.Context, input *CreateTemplateRegistryInput) (*CreateTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -803,9 +812,6 @@ func (h *TemplateHandler) CreateRegistry(ctx context.Context, input *CreateTempl // UpdateRegistry updates a template registry. func (h *TemplateHandler) UpdateRegistry(ctx context.Context, input *UpdateTemplateRegistryInput) (*UpdateTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -839,9 +845,6 @@ func (h *TemplateHandler) UpdateRegistry(ctx context.Context, input *UpdateTempl // DeleteRegistry deletes a template registry. func (h *TemplateHandler) DeleteRegistry(ctx context.Context, input *DeleteTemplateRegistryInput) (*DeleteTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -869,9 +872,6 @@ func (h *TemplateHandler) DeleteRegistry(ctx context.Context, input *DeleteTempl // FetchRegistry fetches templates from a remote registry URL. func (h *TemplateHandler) FetchRegistry(ctx context.Context, input *FetchTemplateRegistryInput) (*FetchTemplateRegistryOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -900,9 +900,6 @@ func (h *TemplateHandler) FetchRegistry(ctx context.Context, input *FetchTemplat // GetGlobalVariables returns global template variables. func (h *TemplateHandler) GetGlobalVariables(ctx context.Context, input *GetGlobalVariablesInput) (*GetGlobalVariablesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -939,9 +936,6 @@ func (h *TemplateHandler) getGlobalVariablesForRemoteEnvironmentInternal(ctx con // UpdateGlobalVariables updates global template variables. func (h *TemplateHandler) UpdateGlobalVariables(ctx context.Context, input *UpdateGlobalVariablesInput) (*UpdateGlobalVariablesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.templateService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/handlers/templates_test.go b/backend/api/handlers/templates_test.go index 274dee874b..225a5eb9af 100644 --- a/backend/api/handlers/templates_test.go +++ b/backend/api/handlers/templates_test.go @@ -17,6 +17,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" glsqlite "github.com/glebarez/sqlite" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" @@ -130,7 +131,6 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *echo.Ech _, err = userService.CreateUser(context.Background(), &models.User{ BaseModel: models.BaseModel{ID: "user-1"}, Username: "alice", - Roles: models.StringSlice{"admin"}, }) require.NoError(t, err) require.NoError(t, db.Create(&models.UserSession{ @@ -141,7 +141,7 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *echo.Ech ExpiresAt: time.Now().Add(time.Hour), }).Error) - authService := services.NewAuthService(userService, nil, nil, services.NewSessionService(databaseDB), "test-secret", &config.Config{}) + authService := services.NewAuthService(userService, nil, nil, services.NewSessionService(databaseDB), nil, "test-secret", &config.Config{}) templateService := services.NewTemplateService(context.Background(), nil, httpClient, nil) router := echo.New() @@ -165,13 +165,25 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *echo.Ech } api := humaecho.NewWithGroup(router, apiGroup, humaConfig) - api.UseMiddleware(humamiddleware.NewAuthBridge(api, authService, nil, nil, &config.Config{})) + api.UseMiddleware(humamiddleware.NewAuthBridge(api, authService, nil, sudoPermResolver{}, nil, &config.Config{})) RegisterHealth(api) RegisterTemplates(api, templateService, nil) return router } +// sudoPermResolver is a test stub satisfying humamiddleware.PermissionResolver +// that grants every permission. Used in tests that don't care about RBAC +// gating — they want auth to succeed and permissions to be unrestricted. +type sudoPermResolver struct{} + +func (sudoPermResolver) ResolvePermissions(_ context.Context, _ *models.User) (*authz.PermissionSet, error) { + return authz.SudoPermissionSet(), nil +} +func (sudoPermResolver) ResolveApiKeyPermissions(_ context.Context, _ string) (*authz.PermissionSet, error) { + return authz.SudoPermissionSet(), nil +} + func makeTemplateFetchToken(t *testing.T, secret string, userID, username string) string { t.Helper() diff --git a/backend/api/handlers/updater.go b/backend/api/handlers/updater.go index a6ea764d2e..5621dd6f81 100644 --- a/backend/api/handlers/updater.go +++ b/backend/api/handlers/updater.go @@ -9,6 +9,8 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/updater" ) @@ -16,6 +18,7 @@ import ( // UpdaterHandler provides Huma-based updater management endpoints. type UpdaterHandler struct { updaterService *services.UpdaterService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -56,9 +59,10 @@ type GetUpdaterHistoryOutput struct { } // RegisterUpdater registers updater management routes using Huma. -func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { +func RegisterUpdater(api huma.API, updaterService *services.UpdaterService, appCtx ActivityAppContext) { h := &UpdaterHandler{ updaterService: updaterService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -72,7 +76,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.RunUpdater) huma.Register(api, huma.Operation{ @@ -86,6 +90,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdaterStatus) huma.Register(api, huma.Operation{ @@ -99,6 +104,7 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesRead), }, h.GetUpdaterHistory) huma.Register(api, huma.Operation{ @@ -112,15 +118,12 @@ func RegisterUpdater(api huma.API, updaterService *services.UpdaterService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermImageUpdatesCheck), }, h.UpdateContainer) } // RunUpdater applies pending container updates. func (h *UpdaterHandler) RunUpdater(ctx context.Context, input *RunUpdaterInput) (*RunUpdaterOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.updaterService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -130,7 +133,8 @@ func (h *UpdaterHandler) RunUpdater(ctx context.Context, input *RunUpdaterInput) dryRun = input.Body.DryRun } - out, err := h.updaterService.ApplyPending(ctx, dryRun) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + out, err := h.updaterService.ApplyPending(runtimeCtx, dryRun) if err != nil { return nil, huma.Error500InternalServerError((&common.UpdaterRunError{Err: err}).Error()) } @@ -185,14 +189,12 @@ func (h *UpdaterHandler) GetUpdaterHistory(ctx context.Context, input *GetUpdate // UpdateContainer updates a single container by pulling the latest image and applying the appropriate update flow. func (h *UpdaterHandler) UpdateContainer(ctx context.Context, input *UpdateContainerInput) (*UpdateContainerOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.updaterService == nil { return nil, huma.Error500InternalServerError("service not available") } - out, err := h.updaterService.UpdateSingleContainer(ctx, input.ContainerID) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + out, err := h.updaterService.UpdateSingleContainer(runtimeCtx, input.ContainerID) if err != nil { return nil, huma.Error500InternalServerError((&common.UpdaterRunError{Err: err}).Error()) } diff --git a/backend/api/handlers/users.go b/backend/api/handlers/users.go index ca7199aac3..bbf9341451 100644 --- a/backend/api/handlers/users.go +++ b/backend/api/handlers/users.go @@ -11,6 +11,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/utils/validation" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/user" @@ -97,7 +98,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersList), }, h.ListUsers) huma.Register(api, huma.Operation{ @@ -111,7 +112,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersCreate), }, h.CreateUser) huma.Register(api, huma.Operation{ @@ -125,7 +126,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersRead), }, h.GetUser) huma.Register(api, huma.Operation{ @@ -139,7 +140,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersUpdate), }, h.UpdateUser) huma.Register(api, huma.Operation{ @@ -153,7 +154,7 @@ func RegisterUsers(api huma.API, userService *services.UserService, authService {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermUsersDelete), }, h.DeleteUser) } @@ -167,11 +168,7 @@ func (h *UserHandler) ListUsers(ctx context.Context, input *ListUsersInput) (*Li return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - - params := buildPaginationParamsInternal(0, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) users, paginationResp, err := h.userService.ListUsersPaginated(ctx, params) if err != nil { @@ -199,10 +196,6 @@ func (h *UserHandler) CreateUser(ctx context.Context, input *CreateUserInput) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - normalizedEmail, err := normalizeOptionalEmailInternal(input.Body.Email) if err != nil { return nil, huma.Error400BadRequest(err.Error()) @@ -219,17 +212,12 @@ func (h *UserHandler) CreateUser(ctx context.Context, input *CreateUserInput) (* PasswordHash: hashedPassword, DisplayName: input.Body.DisplayName, Email: input.Body.Email, - Roles: input.Body.Roles, Locale: input.Body.Locale, BaseModel: models.BaseModel{ CreatedAt: time.Now(), }, } - if userModel.Roles == nil { - userModel.Roles = []string{"user"} - } - createdUser, err := h.userService.CreateUser(ctx, userModel) if err != nil { return nil, huma.Error500InternalServerError((&common.UserCreationError{Err: err}).Error()) @@ -254,10 +242,6 @@ func (h *UserHandler) GetUser(ctx context.Context, input *GetUserInput) (*GetUse return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - userModel, err := h.userService.GetUserByID(ctx, input.UserID) if err != nil { return nil, huma.Error404NotFound((&common.UserNotFoundError{}).Error()) @@ -282,10 +266,6 @@ func (h *UserHandler) UpdateUser(ctx context.Context, input *UpdateUserInput) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - userModel, err := h.userService.GetUserByID(ctx, input.UserID) if err != nil { return nil, huma.Error404NotFound((&common.UserNotFoundError{}).Error()) @@ -306,9 +286,6 @@ func (h *UserHandler) UpdateUser(ctx context.Context, input *UpdateUserInput) (* if input.Body.Email != nil { userModel.Email = input.Body.Email } - if input.Body.Roles != nil { - userModel.Roles = input.Body.Roles - } if input.Body.Locale != nil { userModel.Locale = input.Body.Locale } @@ -357,10 +334,6 @@ func (h *UserHandler) DeleteUser(ctx context.Context, input *DeleteUserInput) (* return nil, huma.Error500InternalServerError("service not available") } - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if err := h.userService.DeleteUser(ctx, input.UserID); err != nil { if errors.Is(err, services.ErrCannotRemoveLastAdmin) { return nil, huma.Error409Conflict(services.ErrCannotRemoveLastAdmin.Error()) diff --git a/backend/api/handlers/users_test.go b/backend/api/handlers/users_test.go index 732864a9f4..9913be812e 100644 --- a/backend/api/handlers/users_test.go +++ b/backend/api/handlers/users_test.go @@ -9,7 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" - usertypes "github.com/getarcaneapp/arcane/types/user" + "github.com/getarcaneapp/arcane/backend/pkg/authz" glsqlite "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -27,13 +27,12 @@ func setupUserHandlerTestDB(t *testing.T) *database.DB { return &database.DB{DB: db} } -func createHandlerTestUser(t *testing.T, svc *services.UserService, id, username string, roles models.StringSlice) *models.User { +func createHandlerTestUser(t *testing.T, svc *services.UserService, id, username string, _ models.StringSlice) *models.User { t.Helper() user := &models.User{ BaseModel: models.BaseModel{ID: id}, Username: username, - Roles: roles, } created, err := svc.CreateUser(context.Background(), user) @@ -43,14 +42,20 @@ func createHandlerTestUser(t *testing.T, svc *services.UserService, id, username } func adminContext() context.Context { - return context.WithValue(context.Background(), humamw.ContextKeyUserIsAdmin, true) + return context.WithValue(context.Background(), humamw.ContextKeyUserPermissions, authz.SudoPermissionSet()) } func TestDeleteUserReturnsConflictForLastAdmin(t *testing.T) { db := setupUserHandlerTestDB(t) - userSvc := services.NewUserService(db) + require.NoError(t, db.AutoMigrate(&models.Role{}, &models.UserRoleAssignment{}, &models.Environment{})) + roleSvc := services.NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(context.Background())) + userSvc := services.NewUserService(db).WithRoleService(roleSvc) handler := &UserHandler{userService: userSvc} - admin := createHandlerTestUser(t, userSvc, "admin-1", "arcane", models.StringSlice{"admin"}) + admin := createHandlerTestUser(t, userSvc, "admin-1", "arcane", models.StringSlice{}) + require.NoError(t, roleSvc.SetUserAssignments(context.Background(), admin.ID, []models.UserRoleAssignment{ + {RoleID: authz.BuiltInRoleAdmin, EnvironmentID: nil}, + })) _, err := handler.DeleteUser(adminContext(), &DeleteUserInput{UserID: admin.ID}) require.Error(t, err) @@ -60,23 +65,3 @@ func TestDeleteUserReturnsConflictForLastAdmin(t *testing.T) { require.Equal(t, http.StatusConflict, statusErr.GetStatus()) require.Contains(t, statusErr.Error(), services.ErrCannotRemoveLastAdmin.Error()) } - -func TestUpdateUserReturnsConflictWhenRemovingLastAdminRole(t *testing.T) { - db := setupUserHandlerTestDB(t) - userSvc := services.NewUserService(db) - handler := &UserHandler{userService: userSvc} - admin := createHandlerTestUser(t, userSvc, "admin-1", "arcane", models.StringSlice{"ADMIN"}) - - _, err := handler.UpdateUser(adminContext(), &UpdateUserInput{ - UserID: admin.ID, - Body: usertypes.UpdateUser{ - Roles: []string{"user"}, - }, - }) - require.Error(t, err) - - var statusErr huma.StatusError - require.ErrorAs(t, err, &statusErr) - require.Equal(t, http.StatusConflict, statusErr.GetStatus()) - require.Contains(t, statusErr.Error(), services.ErrCannotRemoveLastAdmin.Error()) -} diff --git a/backend/api/handlers/volumes.go b/backend/api/handlers/volumes.go index ed7aa05c1e..461c49f6a3 100644 --- a/backend/api/handlers/volumes.go +++ b/backend/api/handlers/volumes.go @@ -13,7 +13,10 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" volumetypes "github.com/getarcaneapp/arcane/types/volume" "github.com/moby/moby/client" @@ -21,8 +24,10 @@ import ( // VolumeHandler provides Huma-based volume management endpoints. type VolumeHandler struct { - volumeService *services.VolumeService - dockerService *services.DockerClientService + volumeService *services.VolumeService + dockerService *services.DockerClientService + activityService *services.ActivityService + appCtx context.Context } // --- Huma Input/Output Wrappers --- @@ -95,6 +100,7 @@ type PruneVolumesInput struct { type VolumePruneReportData struct { VolumesDeleted []string `json:"volumesDeleted,omitempty"` SpaceReclaimed uint64 `json:"spaceReclaimed"` + ActivityID *string `json:"activityId,omitempty"` } type PruneVolumesOutput struct { @@ -294,10 +300,14 @@ type UploadAndRestoreOutput struct { } // RegisterVolumes registers volume management routes using Huma. -func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService) { +// +//nolint:maintidx // long but flat Huma route-registration function; complexity is sequential, not branching +func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, volumeService *services.VolumeService, activityService *services.ActivityService, appCtx ActivityAppContext) { h := &VolumeHandler{ - volumeService: volumeService, - dockerService: dockerService, + volumeService: volumeService, + dockerService: dockerService, + activityService: activityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -311,6 +321,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesList), }, h.GetVolumeUsageCounts) huma.Register(api, huma.Operation{ @@ -324,6 +335,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesList), }, h.ListVolumes) huma.Register(api, huma.Operation{ @@ -337,6 +349,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesRead), }, h.GetVolume) huma.Register(api, huma.Operation{ @@ -350,7 +363,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesCreate), }, h.CreateVolume) huma.Register(api, huma.Operation{ @@ -364,7 +377,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesDelete), }, h.RemoveVolume) huma.Register(api, huma.Operation{ @@ -378,7 +391,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesPrune), }, h.PruneVolumes) huma.Register(api, huma.Operation{ @@ -392,6 +405,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesRead), }, h.GetVolumeUsage) huma.Register(api, huma.Operation{ @@ -405,6 +419,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesList), }, h.GetVolumeSizes) // --- Volume Browsing Endpoints --- @@ -419,6 +434,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.BrowseDirectory) huma.Register(api, huma.Operation{ @@ -431,6 +447,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.GetFileContent) huma.Register(api, huma.Operation{ @@ -443,6 +460,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.DownloadFile) huma.Register(api, huma.Operation{ @@ -472,7 +490,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesUpload), }, h.UploadFile) huma.Register(api, huma.Operation{ @@ -485,7 +503,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesUpload), }, h.CreateDirectory) huma.Register(api, huma.Operation{ @@ -498,7 +516,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesDelete), }, h.DeleteFile) // --- Volume Backup Endpoints --- @@ -513,6 +531,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.ListBackups) huma.Register(api, huma.Operation{ @@ -525,7 +544,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.CreateBackup) huma.Register(api, huma.Operation{ @@ -538,7 +557,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.RestoreBackup) huma.Register(api, huma.Operation{ @@ -551,7 +570,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.RestoreBackupFiles) huma.Register(api, huma.Operation{ @@ -564,7 +583,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBackup), }, h.DeleteBackup) huma.Register(api, huma.Operation{ @@ -577,6 +596,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.DownloadBackup) huma.Register(api, huma.Operation{ @@ -589,6 +609,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.BackupHasPath) huma.Register(api, huma.Operation{ @@ -601,6 +622,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVolumesBrowse), }, h.ListBackupFiles) huma.Register(api, huma.Operation{ @@ -630,7 +652,7 @@ func RegisterVolumes(api huma.API, dockerService *services.DockerClientService, }, }, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVolumesUpload), }, h.UploadAndRestore) } @@ -653,7 +675,7 @@ func (h *VolumeHandler) ListVolumes(ctx context.Context, input *ListVolumesInput Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -714,9 +736,6 @@ func (h *VolumeHandler) GetVolume(ctx context.Context, input *GetVolumeInput) (* // CreateVolume creates a new Docker volume. func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInput) (*CreateVolumeOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -733,10 +752,31 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp DriverOpts: input.Body.DriverOpts, } - response, err := h.volumeService.CreateVolume(ctx, options, *user) + var response *volumetypes.Volume + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.Body.Name, + ResourceName: input.Body.Name, + User: user, + Step: "Creating volume", + Message: "Creating volume", + SuccessMessage: "Volume created successfully", + Metadata: models.JSON{ + "action": "create_volume", + "driver": input.Body.Driver, + }, + }, func(runtimeCtx context.Context) error { + var createErr error + response, createErr = h.volumeService.CreateVolume(runtimeCtx, options, *user) + return createErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.VolumeCreationError{Err: err}).Error()) } + response.ActivityID = utils.StringPtrFromTrimmed(activityID) return &CreateVolumeOutput{ Body: base.ApiResponse[*volumetypes.Volume]{ @@ -748,9 +788,6 @@ func (h *VolumeHandler) CreateVolume(ctx context.Context, input *CreateVolumeInp // RemoveVolume removes a Docker volume. func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInput) (*RemoveVolumeOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -760,7 +797,25 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - if err := h.volumeService.DeleteVolume(ctx, input.VolumeName, input.Force, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Removing volume", + Message: "Removing volume", + SuccessMessage: "Volume removed successfully", + Metadata: models.JSON{ + "action": "remove_volume", + "force": input.Force, + }, + }, func(runtimeCtx context.Context) error { + return h.volumeService.DeleteVolume(runtimeCtx, input.VolumeName, input.Force, *user) + }) + if err != nil { return nil, huma.Error500InternalServerError((&common.VolumeDeletionError{Err: err}).Error()) } @@ -768,7 +823,8 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp Body: base.ApiResponse[base.MessageResponse]{ Success: true, Data: base.MessageResponse{ - Message: "Volume removed successfully", + Message: "Volume removed successfully", + ActivityID: utils.StringPtrFromTrimmed(activityID), }, }, }, nil @@ -776,14 +832,25 @@ func (h *VolumeHandler) RemoveVolume(ctx context.Context, input *RemoveVolumeInp // PruneVolumes removes all unused Docker volumes. func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInput) (*PruneVolumesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } - report, err := h.volumeService.PruneVolumes(ctx) + var report *volumetypes.PruneReport + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + Step: "Pruning unused volumes", + Message: "Pruning unused volumes", + SuccessMessage: "Volumes pruned successfully", + Metadata: models.JSON{"action": "prune_volumes"}, + }, func(runtimeCtx context.Context) error { + var pruneErr error + report, pruneErr = h.volumeService.PruneVolumes(runtimeCtx) + return pruneErr + }) if err != nil { return nil, huma.Error500InternalServerError((&common.VolumePruneError{Err: err}).Error()) } @@ -794,6 +861,7 @@ func (h *VolumeHandler) PruneVolumes(ctx context.Context, input *PruneVolumesInp Data: VolumePruneReportData{ VolumesDeleted: report.VolumesDeleted, SpaceReclaimed: report.SpaceReclaimed, + ActivityID: utils.StringPtrFromTrimmed(activityID), }, }, }, nil @@ -934,9 +1002,6 @@ func (h *VolumeHandler) DownloadFile(ctx context.Context, input *DownloadFileInp } func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -953,49 +1018,95 @@ func (h *VolumeHandler) UploadFile(ctx context.Context, input *UploadFileInput) defer func() { _ = file.Close() }() user, _ := humamw.GetCurrentUserFromContext(ctx) - err = h.volumeService.UploadFile(ctx, input.VolumeName, input.Path, file, fileHeader.Filename, user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Uploading file", + Message: "Uploading file to volume", + SuccessMessage: "File uploaded successfully", + Metadata: models.JSON{ + "action": "upload_volume_file", + "path": input.Path, + "filename": fileHeader.Filename, + }, + }, func(runtimeCtx context.Context) error { + return h.volumeService.UploadFile(runtimeCtx, input.VolumeName, input.Path, file, fileHeader.Filename, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "File uploaded successfully"}, + Data: base.MessageResponse{Message: "File uploaded successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, nil } func (h *VolumeHandler) CreateDirectory(ctx context.Context, input *CreateDirectoryInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } - if h.volumeService == nil { - return nil, huma.Error500InternalServerError("service not available") - } - user, _ := humamw.GetCurrentUserFromContext(ctx) - err := h.volumeService.CreateDirectory(ctx, input.VolumeName, input.Path, user) - if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) - } - return &base.ApiResponse[base.MessageResponse]{ - Success: true, - Data: base.MessageResponse{Message: "Directory created successfully"}, - }, nil + return h.runVolumePathActivityInternal(ctx, input.EnvironmentID, input.VolumeName, input.Path, volumePathActivityConfigInternal{ + Step: "Creating directory", + Message: "Creating directory in volume", + SuccessMessage: "Directory created successfully", + MetadataAction: "create_volume_directory", + Action: func(runtimeCtx context.Context, volumeName, path string, user *models.User) error { + return h.volumeService.CreateDirectory(runtimeCtx, volumeName, path, user) + }, + }) } func (h *VolumeHandler) DeleteFile(ctx context.Context, input *DeleteFileInput) (*base.ApiResponse[base.MessageResponse], error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } + return h.runVolumePathActivityInternal(ctx, input.EnvironmentID, input.VolumeName, input.Path, volumePathActivityConfigInternal{ + Step: "Deleting file", + Message: "Deleting file or directory from volume", + SuccessMessage: "Deleted successfully", + MetadataAction: "delete_volume_file", + Action: func(runtimeCtx context.Context, volumeName, path string, user *models.User) error { + return h.volumeService.DeleteFile(runtimeCtx, volumeName, path, user) + }, + }) +} + +type volumePathActivityConfigInternal struct { + Step string + Message string + SuccessMessage string + MetadataAction string + Action func(context.Context, string, string, *models.User) error +} + +func (h *VolumeHandler) runVolumePathActivityInternal(ctx context.Context, environmentID, volumeName, volumePath string, cfg volumePathActivityConfigInternal) (*base.ApiResponse[base.MessageResponse], error) { if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - err := h.volumeService.DeleteFile(ctx, input.VolumeName, input.Path, user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: environmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: volumeName, + ResourceName: volumeName, + User: user, + Step: cfg.Step, + Message: cfg.Message, + SuccessMessage: cfg.SuccessMessage, + Metadata: models.JSON{ + "action": cfg.MetadataAction, + "path": volumePath, + }, + }, func(runtimeCtx context.Context) error { + return cfg.Action(runtimeCtx, volumeName, volumePath, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Deleted successfully"}, + Data: base.MessageResponse{Message: cfg.SuccessMessage, ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, nil } @@ -1014,7 +1125,7 @@ func (h *VolumeHandler) ListBackups(ctx context.Context, input *ListBackupsInput Sort: input.Sort, Order: pagination.SortOrder(input.Order), }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: input.Start, Limit: input.Limit, }, @@ -1053,9 +1164,6 @@ func (h *VolumeHandler) ListBackups(ctx context.Context, input *ListBackupsInput } func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInput) (*CreateBackupOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1064,10 +1172,28 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - backup, err := h.volumeService.CreateBackup(ctx, input.VolumeName, *user) + var backup *models.VolumeBackup + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Creating backup", + Message: "Creating volume backup", + SuccessMessage: "Volume backup created successfully", + Metadata: models.JSON{"action": "create_volume_backup"}, + }, func(runtimeCtx context.Context) error { + var backupErr error + backup, backupErr = h.volumeService.CreateBackup(runtimeCtx, input.VolumeName, *user) + return backupErr + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } + backup.ActivityID = utils.StringPtrFromTrimmed(activityID) return &CreateBackupOutput{ Body: base.ApiResponse[*models.VolumeBackup]{ Success: true, @@ -1077,9 +1203,6 @@ func (h *VolumeHandler) CreateBackup(ctx context.Context, input *CreateBackupInp } func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupInput) (*RestoreBackupOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1088,22 +1211,36 @@ func (h *VolumeHandler) RestoreBackup(ctx context.Context, input *RestoreBackupI return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - err := h.volumeService.RestoreBackup(ctx, input.VolumeName, input.BackupID, *user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Restoring backup", + Message: "Restoring volume backup", + SuccessMessage: "Restore initiated successfully", + Metadata: models.JSON{ + "action": "restore_volume_backup", + "backupId": input.BackupID, + }, + }, func(runtimeCtx context.Context) error { + return h.volumeService.RestoreBackup(runtimeCtx, input.VolumeName, input.BackupID, *user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &RestoreBackupOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Restore initiated successfully"}, + Data: base.MessageResponse{Message: "Restore initiated successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBackupFilesInput) (*RestoreBackupFilesOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1117,14 +1254,33 @@ func (h *VolumeHandler) RestoreBackupFiles(ctx context.Context, input *RestoreBa return nil, huma.Error400BadRequest("paths are required") } - if err := h.volumeService.RestoreBackupFiles(ctx, input.VolumeName, input.BackupID, input.Body.Paths, *user); err != nil { + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Restoring backup files", + Message: "Restoring files from volume backup", + SuccessMessage: "Restore initiated successfully", + Metadata: models.JSON{ + "action": "restore_volume_backup_files", + "backupId": input.BackupID, + "paths": input.Body.Paths, + }, + }, func(runtimeCtx context.Context) error { + return h.volumeService.RestoreBackupFiles(runtimeCtx, input.VolumeName, input.BackupID, input.Body.Paths, *user) + }) + if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &RestoreBackupFilesOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Restore initiated successfully"}, + Data: base.MessageResponse{Message: "Restore initiated successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } @@ -1170,21 +1326,35 @@ func (h *VolumeHandler) ListBackupFiles(ctx context.Context, input *ListBackupFi } func (h *VolumeHandler) DeleteBackup(ctx context.Context, input *DeleteBackupInput) (*DeleteBackupOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } user, _ := humamw.GetCurrentUserFromContext(ctx) - err := h.volumeService.DeleteBackup(ctx, input.BackupID, user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume_backup", + ResourceID: input.BackupID, + ResourceName: input.BackupID, + User: user, + Step: "Deleting backup", + Message: "Deleting volume backup", + SuccessMessage: "Backup deleted successfully", + Metadata: models.JSON{ + "action": "delete_volume_backup", + "backupId": input.BackupID, + }, + }, func(runtimeCtx context.Context) error { + return h.volumeService.DeleteBackup(runtimeCtx, input.BackupID, user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &DeleteBackupOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Backup deleted successfully"}, + Data: base.MessageResponse{Message: "Backup deleted successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } @@ -1214,9 +1384,6 @@ func (h *VolumeHandler) DownloadBackup(ctx context.Context, input *DownloadBacku } func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRestoreInput) (*UploadAndRestoreOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.volumeService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -1237,14 +1404,31 @@ func (h *VolumeHandler) UploadAndRestore(ctx context.Context, input *UploadAndRe } defer func() { _ = file.Close() }() - err = h.volumeService.UploadAndRestore(ctx, input.VolumeName, file, fileHeader.Filename, *user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + activityID, err := activitylib.RunHandlerActivity(runtimeCtx, h.activityService, activitylib.HandlerOptions{ + EnvironmentID: input.EnvironmentID, + Type: models.ActivityTypeResourceAction, + ResourceType: "volume", + ResourceID: input.VolumeName, + ResourceName: input.VolumeName, + User: user, + Step: "Uploading backup", + Message: "Uploading and restoring volume backup", + SuccessMessage: "Backup uploaded and restored successfully", + Metadata: models.JSON{ + "action": "upload_restore_volume_backup", + "filename": fileHeader.Filename, + }, + }, func(runtimeCtx context.Context) error { + return h.volumeService.UploadAndRestore(runtimeCtx, input.VolumeName, file, fileHeader.Filename, *user) + }) if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } return &UploadAndRestoreOutput{ Body: base.ApiResponse[base.MessageResponse]{ Success: true, - Data: base.MessageResponse{Message: "Backup uploaded and restored successfully"}, + Data: base.MessageResponse{Message: "Backup uploaded and restored successfully", ActivityID: utils.StringPtrFromTrimmed(activityID)}, }, }, nil } diff --git a/backend/api/handlers/vulnerabilities.go b/backend/api/handlers/vulnerabilities.go index 90273f0c89..65c7186f04 100644 --- a/backend/api/handlers/vulnerabilities.go +++ b/backend/api/handlers/vulnerabilities.go @@ -8,6 +8,8 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/vulnerability" ) @@ -15,6 +17,7 @@ import ( // VulnerabilityHandler provides Huma-based vulnerability scanning endpoints. type VulnerabilityHandler struct { vulnerabilityService *services.VulnerabilityService + appCtx context.Context } // --- Huma Input/Output Types --- @@ -63,8 +66,8 @@ type ListImageVulnerabilitiesInput struct { Order string `query:"order" doc:"Sort order"` Start int `query:"start" doc:"Start offset"` Limit int `query:"limit" doc:"Limit"` - Page int `query:"page" doc:"Page number"` - Severity string `query:"severity" doc:"Comma-separated severity filter"` + + Severity string `query:"severity" doc:"Comma-separated severity filter"` } type ListImageVulnerabilitiesOutput struct { @@ -86,9 +89,9 @@ type ListAllVulnerabilitiesInput struct { Order string `query:"order" doc:"Sort order"` Start int `query:"start" doc:"Start offset"` Limit int `query:"limit" doc:"Limit"` - Page int `query:"page" doc:"Page number"` - Severity string `query:"severity" doc:"Comma-separated severity filter"` - ImageName string `query:"imageName" doc:"Filter by image/repo name (substring)"` + + Severity string `query:"severity" doc:"Comma-separated severity filter"` + ImageName string `query:"imageName" doc:"Filter by image/repo name (substring)"` } type ListAllVulnerabilitiesOutput struct { @@ -121,9 +124,10 @@ type GetScannerStatusOutput struct { } // RegisterVulnerability registers vulnerability scanning routes using Huma. -func RegisterVulnerability(api huma.API, vulnerabilityService *services.VulnerabilityService) { +func RegisterVulnerability(api huma.API, vulnerabilityService *services.VulnerabilityService, appCtx ActivityAppContext) { h := &VulnerabilityHandler{ vulnerabilityService: vulnerabilityService, + appCtx: appCtx.contextInternal(), } huma.Register(api, huma.Operation{ @@ -137,7 +141,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVulnsScan), }, h.ScanImage) huma.Register(api, huma.Operation{ @@ -151,6 +155,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScanResult) huma.Register(api, huma.Operation{ @@ -164,6 +169,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScanSummary) huma.Register(api, huma.Operation{ @@ -177,6 +183,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScanSummaries) huma.Register(api, huma.Operation{ @@ -190,6 +197,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListImageVulnerabilities) huma.Register(api, huma.Operation{ @@ -203,6 +211,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetScannerStatus) huma.Register(api, huma.Operation{ @@ -216,6 +225,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.GetEnvironmentSummary) huma.Register(api, huma.Operation{ @@ -229,6 +239,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListAllVulnerabilities) huma.Register(api, huma.Operation{ @@ -242,6 +253,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListAllVulnerabilityImageOptions) huma.Register(api, huma.Operation{ @@ -255,7 +267,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVulnsManage), }, h.IgnoreVulnerability) huma.Register(api, huma.Operation{ @@ -269,7 +281,7 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermVulnsManage), }, h.UnignoreVulnerability) huma.Register(api, huma.Operation{ @@ -283,14 +295,12 @@ func RegisterVulnerability(api huma.API, vulnerabilityService *services.Vulnerab {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermVulnsRead), }, h.ListIgnoredVulnerabilities) } // ScanImage initiates a vulnerability scan for an image. func (h *VulnerabilityHandler) ScanImage(ctx context.Context, input *ScanImageInput) (*ScanImageOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.vulnerabilityService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -300,7 +310,8 @@ func (h *VulnerabilityHandler) ScanImage(ctx context.Context, input *ScanImageIn return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error()) } - result, err := h.vulnerabilityService.ScanImage(ctx, input.EnvironmentID, input.ImageID, *user) + runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx) + result, err := h.vulnerabilityService.ScanImage(runtimeCtx, input.EnvironmentID, input.ImageID, *user) if err != nil { return nil, huma.Error500InternalServerError((&common.VulnerabilityScanError{Err: err}).Error()) } @@ -398,7 +409,7 @@ func (h *VulnerabilityHandler) ListImageVulnerabilities(ctx context.Context, inp return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(input.Page, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } @@ -459,7 +470,7 @@ func (h *VulnerabilityHandler) ListAllVulnerabilities(ctx context.Context, input return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(input.Page, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } @@ -548,9 +559,6 @@ type IgnoreVulnerabilityOutput struct { // IgnoreVulnerability creates an ignore record for a vulnerability. func (h *VulnerabilityHandler) IgnoreVulnerability(ctx context.Context, input *IgnoreVulnerabilityInput) (*IgnoreVulnerabilityOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.vulnerabilityService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -600,9 +608,6 @@ type UnignoreVulnerabilityOutput struct { // UnignoreVulnerability removes an ignore record for a vulnerability. func (h *VulnerabilityHandler) UnignoreVulnerability(ctx context.Context, input *UnignoreVulnerabilityInput) (*UnignoreVulnerabilityOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.vulnerabilityService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -628,7 +633,6 @@ type ListIgnoredVulnerabilitiesInput struct { Order string `query:"order" doc:"Sort order"` Start int `query:"start" doc:"Start offset"` Limit int `query:"limit" doc:"Limit"` - Page int `query:"page" doc:"Page number"` } type ListIgnoredVulnerabilitiesOutput struct { @@ -641,7 +645,7 @@ func (h *VulnerabilityHandler) ListIgnoredVulnerabilities(ctx context.Context, i return nil, huma.Error500InternalServerError("service not available") } - params := buildPaginationParamsInternal(input.Page, input.Start, input.Limit, input.Sort, input.Order, input.Search) + params := buildPaginationParamsInternal(input.Start, input.Limit, input.Sort, input.Order, input.Search) if params.Limit == 0 { params.Limit = 20 } diff --git a/backend/api/handlers/webhooks.go b/backend/api/handlers/webhooks.go index 60d36860eb..f130bb4204 100644 --- a/backend/api/handlers/webhooks.go +++ b/backend/api/handlers/webhooks.go @@ -9,6 +9,7 @@ import ( humamw "github.com/getarcaneapp/arcane/backend/api/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/base" "github.com/getarcaneapp/arcane/types/webhook" ) @@ -70,6 +71,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksList), }, h.ListWebhooks) huma.Register(api, huma.Operation{ @@ -83,7 +85,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksCreate), }, h.CreateWebhook) huma.Register(api, huma.Operation{ @@ -97,7 +99,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksUpdate), }, h.UpdateWebhook) huma.Register(api, huma.Operation{ @@ -111,7 +113,7 @@ func RegisterWebhooks(api huma.API, webhookService *services.WebhookService) { {"BearerAuth": {}}, {"ApiKeyAuth": {}}, }, - Middlewares: humamw.RequireAdmin(api), + Middlewares: humamw.RequirePermission(api, authz.PermWebhooksDelete), }, h.DeleteWebhook) } @@ -136,9 +138,6 @@ func (h *WebhookHandler) ListWebhooks(ctx context.Context, input *ListWebhooksIn // CreateWebhook creates a new webhook and returns the raw token (shown once only). func (h *WebhookHandler) CreateWebhook(ctx context.Context, input *CreateWebhookInput) (*CreateWebhookOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.webhookService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -191,9 +190,6 @@ func (h *WebhookHandler) CreateWebhook(ctx context.Context, input *CreateWebhook // UpdateWebhook updates a webhook's enabled state. func (h *WebhookHandler) UpdateWebhook(ctx context.Context, input *UpdateWebhookInput) (*UpdateWebhookOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.webhookService == nil { return nil, huma.Error500InternalServerError("service not available") } @@ -222,9 +218,6 @@ func (h *WebhookHandler) UpdateWebhook(ctx context.Context, input *UpdateWebhook // DeleteWebhook removes a webhook. func (h *WebhookHandler) DeleteWebhook(ctx context.Context, input *DeleteWebhookInput) (*DeleteWebhookOutput, error) { - if err := checkAdminInternal(ctx); err != nil { - return nil, err - } if h.webhookService == nil { return nil, huma.Error500InternalServerError("service not available") } diff --git a/backend/api/middleware/auth.go b/backend/api/middleware/auth.go index 0e8fb817f7..ae3fb6b0cf 100644 --- a/backend/api/middleware/auth.go +++ b/backend/api/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "context" "errors" + "log/slog" "net/http" "strings" @@ -11,6 +12,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" ) @@ -25,8 +27,9 @@ const ( ContextKeyCurrentUser ContextKey = "currentUser" // ContextKeyCurrentSessionID is the context key for the authenticated session ID. ContextKeyCurrentSessionID ContextKey = "currentSessionID" - // ContextKeyUserIsAdmin is the context key for whether the user is an admin. - ContextKeyUserIsAdmin ContextKey = "userIsAdmin" + // ContextKeyUserPermissions is the context key for the caller's resolved + // PermissionSet, attached by the auth bridge. + ContextKeyUserPermissions ContextKey = "userPermissions" // ContextKeyRemoteAddr is the context key for the request remote address. ContextKeyRemoteAddr ContextKey = "remoteAddr" ) @@ -49,10 +52,11 @@ func GetCurrentSessionIDFromContext(ctx context.Context) (string, bool) { return sessionID, ok } -// IsAdminFromContext checks if the current user is an admin. -func IsAdminFromContext(ctx context.Context) bool { - isAdmin, ok := ctx.Value(ContextKeyUserIsAdmin).(bool) - return ok && isAdmin +// PermissionsFromContext retrieves the caller's resolved PermissionSet. +// Returns nil, false on unauthenticated paths. +func PermissionsFromContext(ctx context.Context) (*authz.PermissionSet, bool) { + ps, ok := ctx.Value(ContextKeyUserPermissions).(*authz.PermissionSet) + return ps, ok } // GetRemoteAddrFromContext retrieves the request remote address from context. @@ -76,6 +80,13 @@ type environmentAccessTokenResolver interface { ResolveEnvironmentByAccessToken(ctx context.Context, token string) (*models.Environment, error) } +// PermissionResolver resolves a caller's effective permission set. Implemented +// by services.RoleService; kept as an interface so tests can stub it. +type PermissionResolver interface { + ResolvePermissions(ctx context.Context, user *models.User) (*authz.PermissionSet, error) + ResolveApiKeyPermissions(ctx context.Context, apiKeyID string) (*authz.PermissionSet, error) +} + // parseSecurityRequirementsInternal extracts security requirements from a Huma operation. func parseSecurityRequirementsInternal(api huma.API, ctx operationProvider) securityRequirements { reqs := securityRequirements{} @@ -126,19 +137,21 @@ func tryBearerAuthInternal(ctx huma.Context, authService *services.AuthService) return user, sessionID, nil } -// tryApiKeyAuthInternal checks if API key authentication should be allowed through. -func tryApiKeyAuthInternal(ctx huma.Context, apiKeyService *services.ApiKeyService) (*models.User, bool) { +// tryApiKeyAuthInternal checks if API key authentication should be allowed +// through. Returns the resolved user plus the API key's database ID so the +// caller can fetch the key's own permission set. +func tryApiKeyAuthInternal(ctx huma.Context, apiKeyService *services.ApiKeyService) (*models.User, string, bool) { apiKey := ctx.Header(pkgutils.HeaderApiKey) if apiKey == "" { - return nil, false + return nil, "", false } - user, err := apiKeyService.ValidateApiKey(ctx.Context(), apiKey) + user, keyID, err := apiKeyService.ValidateApiKeyWithID(ctx.Context(), apiKey) if err != nil || user == nil { - return nil, false + return nil, "", false } - return user, true + return user, keyID, true } func tryEnvironmentAccessTokenAuthInternal(ctx huma.Context, resolver environmentAccessTokenResolver, token string) (*models.User, bool) { @@ -184,12 +197,13 @@ func tryAgentAuthInternal(ctx huma.Context, cfg *config.Config) (*models.User, b } // createAgentSudoUserInternal creates a sudo user for agent authentication. +// The PermissionSet attached to the context (via setUserInContextWithSudoInternal) +// bypasses every check; the user's Roles field is intentionally empty. func createAgentSudoUserInternal() *models.User { return &models.User{ BaseModel: models.BaseModel{ID: "agent"}, Email: new("agent@getarcane.app"), Username: "agent", - Roles: []string{"admin"}, } } @@ -197,13 +211,14 @@ func createEnvironmentSudoUserInternal(env *models.Environment) *models.User { return &models.User{ BaseModel: models.BaseModel{ID: "environment:" + env.ID}, Username: env.Name, - Roles: []string{"admin"}, } } -// NewAuthBridge creates a Huma middleware that validates JWT tokens and -// enforces security requirements defined on operations. -func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyService *services.ApiKeyService, envTokenResolver environmentAccessTokenResolver, cfg *config.Config) func(ctx huma.Context, next func(huma.Context)) { +// NewAuthBridge creates a Huma middleware that validates credentials and +// enforces security requirements defined on operations. It also resolves the +// caller's effective PermissionSet via permResolver and stashes it on the +// request context for downstream RequirePermission checks. +func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyService *services.ApiKeyService, permResolver PermissionResolver, envTokenResolver environmentAccessTokenResolver, cfg *config.Config) func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { ctx = huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyRemoteAddr, ctx.RemoteAddr())) if authService == nil { @@ -218,23 +233,23 @@ func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyServic reqs := parseSecurityRequirementsInternal(api, ctx) if !reqs.isRequired { - next(opportunisticBearerAuthInternal(ctx, authService)) + next(opportunisticBearerAuthInternal(ctx, authService, permResolver)) return } if reqs.apiKeyAuth && ctx.Header(pkgutils.HeaderApiKey) != "" { - handleApiKeyAuthInternal(api, ctx, apiKeyService, envTokenResolver, next) + handleApiKeyAuthInternal(api, ctx, apiKeyService, permResolver, envTokenResolver, next) return } if user, ok := tryEnvironmentAccessTokenAuthInternal(ctx, envTokenResolver, ctx.Header(pkgutils.HeaderAgentToken)); ok { - newCtx := setUserInContextInternal(ctx.Context(), user) + newCtx := setUserInContextWithSudoInternal(ctx.Context(), user) next(huma.WithContext(ctx, newCtx)) return } if reqs.bearerAuth { - nextCtx, handled := handleBearerAuthInternal(api, ctx, authService) + nextCtx, handled := handleBearerAuthInternal(api, ctx, authService, permResolver) if handled { if nextCtx != nil { next(nextCtx) @@ -255,13 +270,13 @@ func tryAgentAuthCtxInternal(ctx huma.Context, cfg *config.Config) (huma.Context if !ok { return ctx, false } - return huma.WithContext(ctx, setUserInContextInternal(ctx.Context(), user)), true + return huma.WithContext(ctx, setUserInContextWithSudoInternal(ctx.Context(), user)), true } // opportunisticBearerAuthInternal populates the user/session context if a valid // bearer token is present, but never fails the request. Used for public routes // (e.g. logout) that still need to know who the caller is when a token exists. -func opportunisticBearerAuthInternal(ctx huma.Context, authService *services.AuthService) huma.Context { +func opportunisticBearerAuthInternal(ctx huma.Context, authService *services.AuthService, permResolver PermissionResolver) huma.Context { if extractBearerTokenInternal(ctx) == "" { return ctx } @@ -269,31 +284,33 @@ func opportunisticBearerAuthInternal(ctx huma.Context, authService *services.Aut if err != nil || user == nil { return ctx } - newCtx := setUserInContextInternal(ctx.Context(), user) + newCtx := setUserInContextInternal(ctx.Context(), user, resolveUserPermissionsInternal(ctx.Context(), permResolver, user)) newCtx = context.WithValue(newCtx, ContextKeyCurrentSessionID, sessionID) return huma.WithContext(ctx, newCtx) } // handleApiKeyAuthInternal handles the API-key-present branch. If validation // fails, it writes 401 directly — Bearer is not attempted as fallback. -func handleApiKeyAuthInternal(api huma.API, ctx huma.Context, apiKeyService *services.ApiKeyService, envTokenResolver environmentAccessTokenResolver, next func(huma.Context)) { - if user, ok := tryApiKeyAuthInternal(ctx, apiKeyService); ok { - newCtx := setUserInContextInternal(ctx.Context(), user) +func handleApiKeyAuthInternal(api huma.API, ctx huma.Context, apiKeyService *services.ApiKeyService, permResolver PermissionResolver, envTokenResolver environmentAccessTokenResolver, next func(huma.Context)) { + if user, keyID, ok := tryApiKeyAuthInternal(ctx, apiKeyService); ok { + ps := resolveApiKeyPermissionsInternal(ctx.Context(), permResolver, keyID) + newCtx := setUserInContextInternal(ctx.Context(), user, ps) next(huma.WithContext(ctx, newCtx)) return } if user, ok := tryEnvironmentAccessTokenAuthInternal(ctx, envTokenResolver, ctx.Header(pkgutils.HeaderApiKey)); ok { - newCtx := setUserInContextInternal(ctx.Context(), user) + newCtx := setUserInContextWithSudoInternal(ctx.Context(), user) next(huma.WithContext(ctx, newCtx)) return } _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized: invalid API key") } -func handleBearerAuthInternal(api huma.API, ctx huma.Context, authService *services.AuthService) (huma.Context, bool) { +func handleBearerAuthInternal(api huma.API, ctx huma.Context, authService *services.AuthService, permResolver PermissionResolver) (huma.Context, bool) { user, sessionID, err := tryBearerAuthInternal(ctx, authService) if err == nil && user != nil { - newCtx := setUserInContextInternal(ctx.Context(), user) + ps := resolveUserPermissionsInternal(ctx.Context(), permResolver, user) + newCtx := setUserInContextInternal(ctx.Context(), user, ps) newCtx = context.WithValue(newCtx, ContextKeyCurrentSessionID, sessionID) return huma.WithContext(ctx, newCtx), true } @@ -307,6 +324,34 @@ func handleBearerAuthInternal(api huma.API, ctx huma.Context, authService *servi return nil, false } +// resolveUserPermissionsInternal asks the RoleService for the user's resolved +// PermissionSet. If RoleService is unavailable or the lookup fails (boot-time +// edge cases, broken DB) it returns nil and logs a warning — handlers then +// see deny-all, which is the safe default. +func resolveUserPermissionsInternal(ctx context.Context, permResolver PermissionResolver, user *models.User) *authz.PermissionSet { + if permResolver == nil || user == nil { + return nil + } + ps, err := permResolver.ResolvePermissions(ctx, user) + if err != nil { + slog.WarnContext(ctx, "failed to resolve user permissions", "error", err, "user_id", user.ID) + return nil + } + return ps +} + +func resolveApiKeyPermissionsInternal(ctx context.Context, permResolver PermissionResolver, apiKeyID string) *authz.PermissionSet { + if permResolver == nil || apiKeyID == "" { + return nil + } + ps, err := permResolver.ResolveApiKeyPermissions(ctx, apiKeyID) + if err != nil { + slog.WarnContext(ctx, "failed to resolve api key permissions", "error", err, "api_key_id", apiKeyID) + return nil + } + return ps +} + // extractBearerTokenInternal extracts the JWT token from Authorization header or cookie. func extractBearerTokenInternal(ctx huma.Context) string { // Try Authorization header first @@ -339,10 +384,22 @@ func extractTokenFromCookieHeaderInternal(cookieHeader string) string { return "" } -// setUserInContextInternal adds the authenticated user to the context. -func setUserInContextInternal(ctx context.Context, user *models.User) context.Context { +// setUserInContextInternal adds the authenticated user and the resolved +// PermissionSet to the context. Callers must supply a non-nil PermissionSet; +// pass authz.NewPermissionSet() to express deny-all. +func setUserInContextInternal(ctx context.Context, user *models.User, ps *authz.PermissionSet) context.Context { + if ps == nil { + ps = authz.NewPermissionSet() + } ctx = context.WithValue(ctx, ContextKeyUserID, user.ID) ctx = context.WithValue(ctx, ContextKeyCurrentUser, user) - ctx = context.WithValue(ctx, ContextKeyUserIsAdmin, pkgutils.UserHasRole(user.Roles, "admin")) + ctx = context.WithValue(ctx, ContextKeyUserPermissions, ps) return ctx } + +// setUserInContextWithSudoInternal attaches a sudo PermissionSet (bypasses +// every check) plus the user. Used by the agent token and environment +// access token paths, which are infrastructure-level and not per-user. +func setUserInContextWithSudoInternal(ctx context.Context, user *models.User) context.Context { + return setUserInContextInternal(ctx, user, authz.SudoPermissionSet()) +} diff --git a/backend/api/middleware/auth_test.go b/backend/api/middleware/auth_test.go index a83c1b2168..ded9df8dc0 100644 --- a/backend/api/middleware/auth_test.go +++ b/backend/api/middleware/auth_test.go @@ -55,7 +55,7 @@ func TestNewAuthBridge_AcceptsEnvironmentAccessTokenViaAPIKey(t *testing.T) { } api := humaecho.NewWithGroup(router, apiGroup, humaConfig) - api.UseMiddleware(NewAuthBridge(api, &services.AuthService{}, nil, testEnvironmentAccessResolver{ + api.UseMiddleware(NewAuthBridge(api, &services.AuthService{}, nil, nil, testEnvironmentAccessResolver{ env: &models.Environment{ BaseModel: models.BaseModel{ID: "env-self"}, Name: "Self Target", @@ -174,12 +174,11 @@ func TestNewAuthBridge_OpportunisticAuthOnPublicRoute(t *testing.T) { jwtSecret := "test-secret-please-do-not-use-in-prod" cfg := &config.Config{JWTRefreshExpiry: 24 * time.Hour} - authSvc := services.NewAuthService(userSvc, nil, nil, sessionSvc, jwtSecret, cfg) + authSvc := services.NewAuthService(userSvc, nil, nil, sessionSvc, nil, jwtSecret, cfg) _, err := userSvc.CreateUser(context.Background(), &models.User{ BaseModel: models.BaseModel{ID: "u-logout"}, Username: "logouttest", - Roles: models.StringSlice{"user"}, }) require.NoError(t, err) @@ -207,7 +206,7 @@ func TestNewAuthBridge_OpportunisticAuthOnPublicRoute(t *testing.T) { "BearerAuth": {Type: "http", Scheme: "bearer"}, } api := humaecho.NewWithGroup(router, apiGroup, humaConfig) - api.UseMiddleware(NewAuthBridge(api, authSvc, nil, nil, &config.Config{})) + api.UseMiddleware(NewAuthBridge(api, authSvc, nil, nil, nil, &config.Config{})) var sawSessionID string huma.Register(api, huma.Operation{ diff --git a/backend/api/middleware/role.go b/backend/api/middleware/role.go index 4024bb4dbd..4e77796172 100644 --- a/backend/api/middleware/role.go +++ b/backend/api/middleware/role.go @@ -5,16 +5,46 @@ import ( "net/http" "github.com/danielgtaylor/huma/v2" + + "github.com/getarcaneapp/arcane/backend/pkg/authz" ) -// RequireAdmin returns a per-operation Huma middleware slice that returns 403 -// to non-admin callers. Attach via Operation.Middlewares: +// RequirePermission returns a per-operation Huma middleware that rejects +// callers lacking `perm`. For env-scoped permissions, the env ID is extracted +// from the request path (/environments/{id}/...). For org-level permissions, +// the env ID segment, if any, is ignored. +// +// Attach via Operation.Middlewares: // -// huma.Register(api, huma.Operation{..., Middlewares: middleware.RequireAdmin(api)}, h.Handler) -func RequireAdmin(api huma.API) huma.Middlewares { +// huma.Register(api, huma.Operation{..., Middlewares: middleware.RequirePermission(api, authz.PermContainersStart)}, h.Handler) +func RequirePermission(api huma.API, perm string) huma.Middlewares { + return huma.Middlewares{func(ctx huma.Context, next func(huma.Context)) { + ps, _ := PermissionsFromContext(ctx.Context()) + envID := "" + if authz.IsEnvScoped(perm) { + envID = authz.EnvIDFromPath(ctx.URL().Path) + } + if !ps.Allows(perm, envID) { + if err := huma.WriteErr(api, ctx, http.StatusForbidden, "permission denied: "+perm); err != nil { + slog.WarnContext(ctx.Context(), "failed to write 403 response", "error", err) + } + return + } + next(ctx) + }} +} + +// RequireGlobalAdmin returns a per-operation Huma middleware that rejects any +// caller who is not a global admin (or sudo). Used for operations that are +// intentionally not exposed as delegated permissions — role creation/edits, +// user role assignment, and OIDC mapping management. Keeping these admin-only +// avoids the meta-escalation surface where a holder of `roles:assign` could +// promote themselves via a custom role. +func RequireGlobalAdmin(api huma.API) huma.Middlewares { return huma.Middlewares{func(ctx huma.Context, next func(huma.Context)) { - if !IsAdminFromContext(ctx.Context()) { - if err := huma.WriteErr(api, ctx, http.StatusForbidden, "admin access required"); err != nil { + ps, _ := PermissionsFromContext(ctx.Context()) + if !ps.IsGlobalAdmin() { + if err := huma.WriteErr(api, ctx, http.StatusForbidden, "permission denied: global admin required"); err != nil { slog.WarnContext(ctx.Context(), "failed to write 403 response", "error", err) } return diff --git a/backend/api/middleware/role_test.go b/backend/api/middleware/role_test.go index 9d8f3f3cb2..2bc3933cc6 100644 --- a/backend/api/middleware/role_test.go +++ b/backend/api/middleware/role_test.go @@ -10,67 +10,137 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humaecho" "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" + + "github.com/getarcaneapp/arcane/backend/pkg/authz" ) -func TestRequireAdmin_RejectsNonAdmin(t *testing.T) { +func TestRequirePermission_RejectsCallerMissingPermission(t *testing.T) { router := echo.New() api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { - next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserIsAdmin, false))) + // Caller has containers:list but NOT containers:start. + ps := authz.NewPermissionSet() + ps.AddEnv("env-1", authz.PermContainersList) + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, ps))) }) huma.Register(api, huma.Operation{ - OperationID: "guarded", - Method: http.MethodGet, - Path: "/guarded", - Middlewares: RequireAdmin(api), - }, func(_ context.Context, _ *struct{}) (*struct{}, error) { - t.Fatal("handler must not run for non-admin") + OperationID: "guarded-start", + Method: http.MethodPost, + Path: "/environments/{id}/containers/{cid}/start", + Middlewares: RequirePermission(api, authz.PermContainersStart), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + CID string `path:"cid"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is missing") return nil, nil }) - req := httptest.NewRequest(http.MethodGet, "/api/guarded", nil) + req := httptest.NewRequest(http.MethodPost, "/api/environments/env-1/containers/c/start", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusForbidden, rec.Code) - require.Contains(t, rec.Body.String(), "admin access required") + require.Contains(t, rec.Body.String(), "permission denied: containers:start") } -func TestRequireAdmin_AllowsAdmin(t *testing.T) { +func TestRequirePermission_AllowsCallerWithPermissionOnEnv(t *testing.T) { router := echo.New() api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { - next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserIsAdmin, true))) + ps := authz.NewPermissionSet() + ps.AddEnv("env-1", authz.PermContainersStart) + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, ps))) }) - type guardedAdminOutput struct { + type out struct { Body struct { Success bool `json:"success"` } } - handlerRan := false huma.Register(api, huma.Operation{ - OperationID: "guarded-admin", - Method: http.MethodGet, - Path: "/guarded-admin", - Middlewares: RequireAdmin(api), - }, func(_ context.Context, _ *struct{}) (*guardedAdminOutput, error) { + OperationID: "guarded-start-allow", + Method: http.MethodPost, + Path: "/environments/{id}/containers/{cid}/start", + Middlewares: RequirePermission(api, authz.PermContainersStart), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + CID string `path:"cid"` + }) (*out, error) { handlerRan = true - return &guardedAdminOutput{ - Body: struct { - Success bool `json:"success"` - }{Success: true}, - }, nil + return &out{Body: struct { + Success bool `json:"success"` + }{Success: true}}, nil }) - req := httptest.NewRequest(http.MethodGet, "/api/guarded-admin", nil) + req := httptest.NewRequest(http.MethodPost, "/api/environments/env-1/containers/c/start", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) require.True(t, handlerRan) } + +func TestRequirePermission_EnvScopedDoesNotLeakAcrossEnvs(t *testing.T) { + router := echo.New() + api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) + + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + ps := authz.NewPermissionSet() + // Permission scoped to env-1; request will target env-2. + ps.AddEnv("env-1", authz.PermContainersStart) + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, ps))) + }) + + huma.Register(api, huma.Operation{ + OperationID: "guarded-start-other-env", + Method: http.MethodPost, + Path: "/environments/{id}/containers/{cid}/start", + Middlewares: RequirePermission(api, authz.PermContainersStart), + }, func(_ context.Context, _ *struct { + ID string `path:"id"` + CID string `path:"cid"` + }) (*struct{}, error) { + t.Fatal("handler must not run when permission is scoped to a different env") + return nil, nil + }) + + req := httptest.NewRequest(http.MethodPost, "/api/environments/env-2/containers/c/start", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestRequirePermission_SudoCallerAllowed(t *testing.T) { + router := echo.New() + api := humaecho.NewWithGroup(router, router.Group("/api"), huma.DefaultConfig("test", "1.0.0")) + + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + next(huma.WithContext(ctx, context.WithValue(ctx.Context(), ContextKeyUserPermissions, authz.SudoPermissionSet()))) + }) + + handlerRan := false + huma.Register(api, huma.Operation{ + OperationID: "guarded-sudo", + Method: http.MethodDelete, + Path: "/users/{userId}", + Middlewares: RequirePermission(api, authz.PermUsersDelete), + }, func(_ context.Context, _ *struct { + UserID string `path:"userId"` + }) (*struct{}, error) { + handlerRan = true + return &struct{}{}, nil + }) + + req := httptest.NewRequest(http.MethodDelete, "/api/users/u-1", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Code) + require.True(t, handlerRan) +} diff --git a/backend/api/playwright.go b/backend/api/playwright.go index ee2f0b8282..b1780fd6f9 100644 --- a/backend/api/playwright.go +++ b/backend/api/playwright.go @@ -24,12 +24,21 @@ func SetupPlaywrightRoutes(api *echo.Group, playwrightService *services.Playwrig playwright.POST("/create-test-api-keys", playwrightHandler.CreateTestApiKeysHandler) playwright.POST("/delete-test-api-keys", playwrightHandler.DeleteTestApiKeysHandler) + playwright.POST("/create-test-federated-credential", playwrightHandler.CreateTestFederatedCredentialHandler) } type CreateTestApiKeysRequest struct { Count int `json:"count"` } +type CreateTestFederatedCredentialRequest struct { + IssuerURL string `json:"issuerUrl"` + Audiences []string `json:"audiences"` + Subject string `json:"subject"` + RoleID string `json:"roleId"` + TokenTTLSeconds int `json:"tokenTtlSeconds"` +} + func (ph *PlaywrightHandler) CreateTestApiKeysHandler(c echo.Context) error { var req CreateTestApiKeysRequest if err := c.Bind(&req); err != nil { @@ -55,3 +64,24 @@ func (ph *PlaywrightHandler) DeleteTestApiKeysHandler(c echo.Context) error { return c.NoContent(http.StatusNoContent) } + +func (ph *PlaywrightHandler) CreateTestFederatedCredentialHandler(c echo.Context) error { + var req CreateTestFederatedCredentialRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]any{"error": "invalid request body"}) + } + + credentialID, err := ph.PlaywrightService.CreateTestFederatedCredential( + c.Request().Context(), + req.IssuerURL, + req.Audiences, + req.Subject, + req.RoleID, + req.TokenTTLSeconds, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, map[string]any{"credential": map[string]string{"id": credentialID}}) +} diff --git a/backend/api/webhooks_trigger.go b/backend/api/webhooks_trigger.go index 7cd72f693d..b24b1a787c 100644 --- a/backend/api/webhooks_trigger.go +++ b/backend/api/webhooks_trigger.go @@ -4,21 +4,23 @@ import ( "errors" "net/http" + "github.com/getarcaneapp/arcane/backend/api/handlers" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/labstack/echo/v4" ) // RegisterWebhookTrigger registers the public (unauthenticated) trigger endpoint. // The token in the URL is the sole authentication mechanism. // Rate-limited via PerIPRateLimitForPaths in router_bootstrap.go. -func RegisterWebhookTrigger(g *echo.Group, webhookService *services.WebhookService) { +func RegisterWebhookTrigger(g *echo.Group, webhookService *services.WebhookService, appCtx handlers.ActivityAppContext) { g.POST("/webhooks/trigger/:token", func(c echo.Context) error { if webhookService == nil { return c.JSON(http.StatusInternalServerError, map[string]any{"success": false, "error": "service not available"}) } token := c.Param("token") - result, err := webhookService.TriggerByToken(c.Request().Context(), token) + result, err := webhookService.TriggerByToken(utils.ActivityRuntimeContext(c.Request().Context(), appCtx.ContextInternal()), token) if err != nil { status := http.StatusInternalServerError if errors.Is(err, services.ErrWebhookNotFound) || errors.Is(err, services.ErrWebhookInvalid) { diff --git a/backend/api/ws/diagnostics.go b/backend/api/ws/diagnostics.go new file mode 100644 index 0000000000..9d74e04a11 --- /dev/null +++ b/backend/api/ws/diagnostics.go @@ -0,0 +1,150 @@ +package ws + +import ( + "encoding/json" + "net/http" + "net/http/pprof" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/logstream" + wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" + systemtypes "github.com/getarcaneapp/arcane/types/system" + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" +) + +// diagnosticsStreamInterval is how often the live diagnostics stream pushes a snapshot. +const diagnosticsStreamInterval = 2 * time.Second + +// BuildDiagnostics assembles a full diagnostics snapshot: runtime/memory/GC from +// the DiagnosticsService plus this package's WebSocket metrics and worker-goroutine +// count. Shared by the REST endpoint (via handlers) and the live WebSocket stream. +func BuildDiagnostics(diag *services.DiagnosticsService) systemtypes.Diagnostics { + d := systemtypes.Diagnostics{Timestamp: time.Now().UTC()} + if diag != nil { + d.Runtime, d.Memory, d.GC = diag.Collect() + } + d.Runtime.WSWorkerGoroutines = wshub.CountWorkerGoroutines() + d.WebSocket = systemtypes.WebSocketDiagnostics{ + Snapshot: defaultWebSocketMetrics.Snapshot(), + Connections: defaultWebSocketMetrics.Connections(), + } + return d +} + +// registerDiagnosticsRoutesInternal wires the global diagnostics WebSocket streams and the +// net/http/pprof debug endpoints. Streams require the diagnostics permission; +// pprof keeps the stricter admin-required gate. Called from NewWebSocketHandler. +func (h *WebSocketHandler) registerDiagnosticsRoutesInternal(group *echo.Group, authMiddleware *middleware.AuthMiddleware) { + diag := group.Group("/diagnostics", + authMiddleware.WithAdminNotRequired().Add(), + middleware.RequirePermission(authz.PermDiagnosticsRead), + ) + diag.GET("/stream", h.DiagnosticsStream) + diag.GET("/logs/stream", h.ServerLogsStream) + + pprofGroup := group.Group("/debug/pprof", authMiddleware.WithAdminRequired().Add()) + pprofGroup.GET("", echo.WrapHandler(http.HandlerFunc(pprof.Index))) + pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index))) + pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) + pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) + pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) + pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) + pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace))) + pprofGroup.GET("/:name", echo.WrapHandler(http.HandlerFunc(pprof.Index))) +} + +// DiagnosticsStream pushes a fresh diagnostics snapshot on connect and then every +// diagnosticsStreamInterval until the client disconnects. +func (h *WebSocketHandler) DiagnosticsStream(c echo.Context) error { + conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) + if err != nil { + return nil + } + defer conn.Close() + + done := diagnosticsReadLoopInternal(conn) + write := func() bool { + b, marshalErr := json.Marshal(BuildDiagnostics(h.diagnosticsService)) + if marshalErr != nil { + return true + } + return conn.WriteMessage(websocket.TextMessage, b) == nil + } + + if !write() { + return nil + } + ticker := time.NewTicker(diagnosticsStreamInterval) + defer ticker.Stop() + for { + select { + case <-done: + return nil + case <-ticker.C: + if !write() { + return nil + } + } + } +} + +// ServerLogsStream replays the recent backend log backlog then streams new entries live. +func (h *WebSocketHandler) ServerLogsStream(c echo.Context) error { + conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) + if err != nil { + return nil + } + defer conn.Close() + + // Subscribe before replaying the backlog so no entry is missed in the gap; at + // worst the newest backlog entry is delivered twice, which is harmless. + ch, cancel := logstream.Default().Subscribe() + defer cancel() + + done := diagnosticsReadLoopInternal(conn) + write := func(e systemtypes.LogEntry) bool { + b, marshalErr := json.Marshal(e) + if marshalErr != nil { + return true + } + return conn.WriteMessage(websocket.TextMessage, b) == nil + } + + for _, e := range logstream.Default().Recent() { + if !write(e) { + return nil + } + } + for { + select { + case <-done: + return nil + case e, ok := <-ch: + if !ok { + return nil + } + if !write(e) { + return nil + } + } + } +} + +// diagnosticsReadLoopInternal drains incoming frames; the returned channel closes +// when the peer disconnects, signaling the writer loop to stop. +func diagnosticsReadLoopInternal(conn *websocket.Conn) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + return done +} diff --git a/backend/api/ws/handler.go b/backend/api/ws/handler.go index adc3758521..25151255f8 100644 --- a/backend/api/ws/handler.go +++ b/backend/api/ws/handler.go @@ -18,6 +18,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/middleware" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/system" wshub "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ws" @@ -133,11 +134,6 @@ func (m *WebSocketMetrics) applyDelta(kind string, delta int64) { var defaultWebSocketMetrics = NewWebSocketMetrics() -// DefaultWebSocketMetrics returns the package-level WebSocketMetrics singleton. -func DefaultWebSocketMetrics() *WebSocketMetrics { - return defaultWebSocketMetrics -} - // ============================================================================ // WebSocket Handler // ============================================================================ @@ -145,17 +141,19 @@ func DefaultWebSocketMetrics() *WebSocketMetrics { // WebSocketHandler consolidates all WebSocket and streaming endpoints. // REST endpoints are handled by Huma handlers. type WebSocketHandler struct { - projectService *services.ProjectService - containerService *services.ContainerService - swarmService *services.SwarmService - systemService *services.SystemService - wsUpgrader websocket.Upgrader - wsMetrics *WebSocketMetrics - activeConnections sync.Map - logStreamsMu sync.Mutex - logStreams map[string]*wsLogStream - cpuCache struct { + projectService *services.ProjectService + containerService *services.ContainerService + swarmService *services.SwarmService + systemService *services.SystemService + diagnosticsService *services.DiagnosticsService + wsUpgrader websocket.Upgrader + wsMetrics *WebSocketMetrics + activeConnections sync.Map + logStreamsMu sync.Mutex + logStreams map[string]*wsLogStream + cpuCache struct { sync.RWMutex + value float64 timestamp time.Time } @@ -180,6 +178,7 @@ type WebSocketHandler struct { diskUsagePathCache struct { sync.RWMutex + value string timestamp time.Time } @@ -318,18 +317,20 @@ func NewWebSocketHandler( containerService *services.ContainerService, swarmService *services.SwarmService, systemService *services.SystemService, + diagnosticsService *services.DiagnosticsService, authMiddleware *middleware.AuthMiddleware, cfg *config.Config, ) { handler := &WebSocketHandler{ - projectService: projectService, - containerService: containerService, - swarmService: swarmService, - systemService: systemService, - wsMetrics: defaultWebSocketMetrics, - logStreams: make(map[string]*wsLogStream), - cgroupCache: system.NewCgroupCache(cgroupCacheTTL), - gpuMonitor: system.NewGPUMonitor(cfg.GPUMonitoringEnabled, cfg.GPUType), + projectService: projectService, + containerService: containerService, + swarmService: swarmService, + systemService: systemService, + diagnosticsService: diagnosticsService, + wsMetrics: defaultWebSocketMetrics, + logStreams: make(map[string]*wsLogStream), + cgroupCache: system.NewCgroupCache(cgroupCacheTTL), + gpuMonitor: system.NewGPUMonitor(cfg.GPUMonitoringEnabled, cfg.GPUType), wsUpgrader: websocket.Upgrader{ CheckOrigin: httputil.ValidateWebSocketOrigin(cfg.GetAppURL()), ReadBufferSize: 32 * 1024, @@ -338,12 +339,13 @@ func NewWebSocketHandler( }, } wsGroup := group.Group("/environments/:id/ws", authMiddleware.WithAdminNotRequired().Add()) - wsGroup.GET("/projects/:projectId/logs", handler.ProjectLogs) - wsGroup.GET("/containers/:containerId/logs", handler.ContainerLogs) - wsGroup.GET("/containers/:containerId/stats", handler.ContainerStats) - wsGroup.GET("/containers/:containerId/terminal", handler.ContainerExec) - wsGroup.GET("/swarm/services/:serviceId/logs", handler.ServiceLogs) - wsGroup.GET("/system/stats", handler.SystemStats) + wsGroup.GET("/projects/:projectId/logs", handler.ProjectLogs, middleware.RequirePermission(authz.PermProjectsLogs)) + wsGroup.GET("/containers/:containerId/logs", handler.ContainerLogs, middleware.RequirePermission(authz.PermContainersLogs)) + wsGroup.GET("/containers/:containerId/stats", handler.ContainerStats, middleware.RequirePermission(authz.PermContainersRead)) + wsGroup.GET("/containers/:containerId/terminal", handler.ContainerExec, middleware.RequirePermission(authz.PermContainersExec)) + wsGroup.GET("/swarm/services/:serviceId/logs", handler.ServiceLogs, middleware.RequirePermission(authz.PermSwarmServicesLogs)) + wsGroup.GET("/system/stats", handler.SystemStats, middleware.RequirePermission(authz.PermSystemRead)) + handler.registerDiagnosticsRoutesInternal(group, authMiddleware) } // ============================================================================ @@ -812,7 +814,7 @@ func (h *WebSocketHandler) ContainerStats(c echo.Context) error { conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) if err != nil { slog.DebugContext(c.Request().Context(), "Failed to upgrade WebSocket for container stats", "containerID", containerID, "error", err) - return nil //nolint:nilerr // Upgrade has already written an HTTP error response to the client. + return nil } connID := h.wsMetrics.RegisterConnection(buildWSConnectionInfoInternal(c, systemtypes.WSKindContainerStats, containerID)) @@ -828,13 +830,20 @@ func (h *WebSocketHandler) ContainerStats(c echo.Context) error { func (h *WebSocketHandler) getOrCreateContainerStatsHubInternal(containerID string) *wshub.Hub { if existing, ok := h.containerStatsHubs.Load(containerID); ok { - return existing.(*wshub.Hub) + if hub, ok := existing.(*wshub.Hub); ok { + return hub + } } hub := wshub.NewHub(64) actual, loaded := h.containerStatsHubs.LoadOrStore(containerID, hub) if loaded { - return actual.(*wshub.Hub) + if existingHub, ok := actual.(*wshub.Hub); ok { + return existingHub + } + // type assertion failure is impossible in practice, but avoid running + // an unregistered hub if it somehow occurs + return hub } h.runContainerStatsHubInternal(containerID, hub) @@ -842,7 +851,7 @@ func (h *WebSocketHandler) getOrCreateContainerStatsHubInternal(containerID stri } func (h *WebSocketHandler) runContainerStatsHubInternal(containerID string, hub *wshub.Hub) { - ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // cancel is intentionally retained and invoked by the hub OnEmpty callback. + ctx, cancel := context.WithCancel(context.Background()) var cleanupTimer *time.Timer var cleanupTimerMu sync.Mutex @@ -971,7 +980,7 @@ func (h *WebSocketHandler) execCleanupFuncInternal(ctx context.Context, execSess return func() { slog.Debug("Cleaning up exec session", "execID", execID, "containerID", containerID, "contextErr", ctx.Err()) // Cleanup must proceed even if parent ctx is canceled. - cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) //nolint:contextcheck + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) defer cleanupCancel() if err := execSession.Close(cleanupCtx); err != nil { //nolint:contextcheck slog.Warn("Failed to clean up exec session", "execID", execID, "error", err) @@ -1036,11 +1045,14 @@ func (h *WebSocketHandler) pipeExecInputInternal(ctx context.Context, cancel con // System WebSocket Endpoints // ============================================================================ -// checkRateLimit checks and applies rate limiting for WebSocket connections. +// checkRateLimitInternal checks and applies rate limiting for WebSocket connections. // Returns the counter and whether the connection should be allowed. -func (h *WebSocketHandler) checkRateLimit(clientIP string) (*int32, bool) { +func (h *WebSocketHandler) checkRateLimitInternal(clientIP string) (*int32, bool) { connCount, _ := h.activeConnections.LoadOrStore(clientIP, new(int32)) - count := connCount.(*int32) + count, ok := connCount.(*int32) + if !ok { + return nil, false + } currentCount := atomic.AddInt32(count, 1) if currentCount > 5 { @@ -1050,8 +1062,8 @@ func (h *WebSocketHandler) checkRateLimit(clientIP string) (*int32, bool) { return count, true } -// releaseRateLimit decrements the connection counter and cleans up if needed. -func (h *WebSocketHandler) releaseRateLimit(clientIP string, count *int32) { +// releaseRateLimitInternal decrements the connection counter and cleans up if needed. +func (h *WebSocketHandler) releaseRateLimitInternal(clientIP string, count *int32) { newCount := atomic.AddInt32(count, -1) if newCount <= 0 { h.activeConnections.Delete(clientIP) @@ -1068,7 +1080,7 @@ func (h *WebSocketHandler) acquireSystemStatsSamplerInternal(ctx context.Context return waitForSystemStatsSamplerReadyInternal(ctx, ready) } - samplerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) //nolint:gosec // cancel is intentionally retained in sampler state and invoked when the last subscriber disconnects. + samplerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) ready := make(chan struct{}) h.systemStatsSampler.cancel = cancel h.systemStatsSampler.ready = ready @@ -1370,14 +1382,14 @@ func (h *WebSocketHandler) getCachedCgroupLimitsInternal() *docker.CgroupLimits func (h *WebSocketHandler) SystemStats(c echo.Context) error { clientIP := c.RealIP() - count, allowed := h.checkRateLimit(clientIP) + count, allowed := h.checkRateLimitInternal(clientIP) if !allowed { return c.JSON(http.StatusTooManyRequests, map[string]any{ "success": false, "error": "Too many concurrent stats connections from this IP", }) } - defer h.releaseRateLimit(clientIP, count) + defer h.releaseRateLimitInternal(clientIP, count) conn, err := h.wsUpgrader.Upgrade(c.Response().Writer, c.Request(), nil) if err != nil { diff --git a/backend/cli/generate/generate.go b/backend/cli/generate/generate.go index 95371b9c72..d22679a9d3 100644 --- a/backend/cli/generate/generate.go +++ b/backend/cli/generate/generate.go @@ -2,7 +2,6 @@ package generate import ( cligenerate "github.com/getarcaneapp/arcane/cli/pkg/generate" - "github.com/spf13/cobra" ) -var GenerateCmd *cobra.Command = cligenerate.GenerateCmd +var GenerateCmd = cligenerate.GenerateCmd diff --git a/backend/cli/upgrade/upgrade.go b/backend/cli/upgrade/upgrade.go index da99b1bf0a..0cad3aa63e 100644 --- a/backend/cli/upgrade/upgrade.go +++ b/backend/cli/upgrade/upgrade.go @@ -2,6 +2,7 @@ package upgrade import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -170,7 +171,7 @@ func findArcaneContainer(ctx context.Context, dockerClient *client.Client) (cont } } - return container.InspectResponse{}, fmt.Errorf("no running Arcane container found") + return container.InspectResponse{}, errors.New("no running Arcane container found") } func isLegacyServerLabel(labels map[string]string) bool { diff --git a/backend/frontend/frontend.go b/backend/frontend/frontend.go index 97e8295bb1..95184dd13e 100644 --- a/backend/frontend/frontend.go +++ b/backend/frontend/frontend.go @@ -47,7 +47,7 @@ func RegisterFrontend(e *echo.Echo) error { if strings.HasPrefix(path, "/api/") { _ = c.JSON(http.StatusNotFound, map[string]any{ "success": false, - "error": fmt.Sprintf("API endpoint not found: %s", path), + "error": "API endpoint not found: " + path, }) return } diff --git a/backend/go.mod b/backend/go.mod index 4775367b1d..accea47797 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,12 +17,13 @@ require ( github.com/coreos/go-oidc/v3 v3.18.0 github.com/danielgtaylor/huma/v2 v2.38.0 github.com/depot/depot-go v0.5.2 + github.com/distribution/reference v0.6.0 github.com/docker/cli v29.5.2+incompatible github.com/docker/compose/v5 v5.1.4 github.com/docker/go-units v0.5.0 github.com/fsnotify/fsnotify v1.10.1 github.com/getarcaneapp/arcane/cli v1.19.1 - github.com/getarcaneapp/arcane/types v1.19.4 + github.com/getarcaneapp/arcane/types v1.19.5 github.com/glebarez/sqlite v1.11.0 github.com/go-git/go-git/v5 v5.19.1 github.com/goccy/go-yaml v1.19.2 @@ -30,6 +31,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 + github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-uuid v1.0.3 github.com/jinzhu/copier v0.4.0 @@ -51,7 +53,6 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/wneessen/go-mail v0.7.3 - go.podman.io/image/v5 v5.40.0 golang.org/x/crypto v0.52.0 golang.org/x/mod v0.36.0 golang.org/x/net v0.55.0 @@ -100,7 +101,6 @@ require ( github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/distribution/reference v0.6.0 // indirect github.com/docker/buildx v0.33.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.7 // indirect @@ -125,9 +125,11 @@ require ( github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-containerregistry v0.21.1 // indirect github.com/google/go-github/v39 v39.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -197,28 +199,29 @@ require ( github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vbatts/tar-split v0.12.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.podman.io/storage v1.63.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/ini.v1 v1.67.2 // indirect @@ -231,3 +234,5 @@ require ( modernc.org/sqlite v1.50.1 // indirect tags.cncf.io/container-device-interface v1.1.0 // indirect ) + +tool github.com/google/wire/cmd/wire diff --git a/backend/go.sum b/backend/go.sum index 463b38ecdb..76b8e55ca1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -277,8 +277,12 @@ github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -557,10 +561,10 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0 h1:cuXaPAfIoJKsYjBjPSb2nKZEmgM43zVr25l37IxhKME= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.68.0/go.mod h1:BuzhPofpCzlDi/Q/Xjg54M4/3oWqqyDe2Zeq7A2I0QE= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= @@ -571,20 +575,18 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+J go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.podman.io/image/v5 v5.40.0 h1:gNQvj343Eb4juCitUBkuDz1T82Zpp6nhgMEXzNfCges= -go.podman.io/image/v5 v5.40.0/go.mod h1:qgXf1abXJ+2l01pL8+CljaMKryeo6ahaHO7H51ooKIc= -go.podman.io/storage v1.63.0 h1:bj/pAWFhChbuBmejzno0iQLhU7FevGVXepRXm5pFGeA= -go.podman.io/storage v1.63.0/go.mod h1:z4Z9K+7GhKjWL/Y1O17+4f8a1KGijVeC9hr3tymhSOs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 7c9f492413..7a975d104a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -17,12 +17,15 @@ import ( "github.com/moby/moby/client" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/services" libcrypto "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/logstream" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/startup" "github.com/getarcaneapp/arcane/backend/pkg/scheduler" + "github.com/getarcaneapp/arcane/backend/pkg/utils" httputils "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" "google.golang.org/grpc" ) @@ -44,10 +47,15 @@ func Bootstrap(ctx context.Context) error { cfg.DockerConfig = runtimeIdentityCfg.DockerConfig SetupSlogLogger(cfg) + // Tee all slog output into the in-memory ring buffer that powers the + // diagnostics live log tail. + slog.SetDefault(slog.New(logstream.NewSlogHandler(slog.Default().Handler(), logstream.Default()))) ConfigureGormLogger(cfg) - slog.InfoContext(ctx, "Arcane is starting", "version", config.Version) + slog.InfoContext(ctx, "Arcane is starting...", "version", config.Version) + slog.InfoContext(ctx, "Arcane Identity Configuration", "PUID", os.Getuid(), "PGID", os.Getgid()) appCtx, cancelApp := context.WithCancel(ctx) + appCtx = utils.WithAppLifecycleContext(appCtx) defer cancelApp() db, err := initializeDBAndMigrate(appCtx, cfg) @@ -55,20 +63,23 @@ func Bootstrap(ctx context.Context) error { return fmt.Errorf("failed to initialize database: %w", err) } defer func(ctx context.Context) { - // Use background context for shutdown as appCtx is already canceled - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) //nolint:contextcheck + // appCtx is already canceled here, so derive the shutdown deadline from a + // non-canceled copy of it. + baseCtx := context.WithoutCancel(ctx) + shutdownCtx, shutdownCancel := context.WithTimeout(baseCtx, 10*time.Second) defer shutdownCancel() if err := db.Close(); err != nil { - slog.ErrorContext(shutdownCtx, "Error closing database", "error", err) //nolint:contextcheck + slog.ErrorContext(shutdownCtx, "Error closing database", "error", err) } }(appCtx) httpClient := newConfiguredHTTPClient(cfg) - appServices, dockerClientService, err := initializeServices(appCtx, db, cfg, httpClient) + appServices, err := di.InitializeServices(appCtx, db, cfg, httpClient) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } + dockerClientService := appServices.Docker defer dockerClientService.Close() defer func(ctx context.Context) { baseCtx := context.WithoutCancel(ctx) @@ -90,7 +101,7 @@ func Bootstrap(ctx context.Context) error { startEdgeTunnelClientIfConfigured(appCtx, cfg, router) - err = runServices(appCtx, cfg, router, tunnelServer, scheduler) + err = runServicesInternal(appCtx, cfg, router, tunnelServer, scheduler) if err != nil { return fmt.Errorf("failed to run services: %w", err) } @@ -106,11 +117,9 @@ func newConfiguredHTTPClient(cfg *config.Config) *http.Client { return httputils.NewHTTPClient() } -func initializeStartupState(appCtx context.Context, cfg *config.Config, appServices *Services, dockerClientService *services.DockerClientService, httpClient *http.Client) { +func initializeStartupState(appCtx context.Context, cfg *config.Config, appServices *di.Services, dockerClientService *services.DockerClientService, httpClient *http.Client) { if appServices.Volume != nil { - if err := appServices.Volume.CleanupOrphanedVolumeHelpers(appCtx); err != nil { - slog.WarnContext(appCtx, "Failed to cleanup orphaned volume helpers on startup", "error", err) - } + startup.CleanupOrphanedVolumeHelpers(appCtx, appServices.Volume.CleanupOrphanedVolumeHelpers) } runtimeCfg := &startup.RuntimeConfig{ @@ -133,18 +142,7 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi AgentMode: cfg.AgentMode, }) startup.InitializeDefaultSettings(appCtx, runtimeCfg, appServices.Settings) - startup.MigrateSchedulerCronValues( - appCtx, - appServices.Settings.GetStringSetting, - appServices.Settings.UpdateSetting, - appServices.Settings.LoadDatabaseSettings, - ) if appServices.GitOpsSync != nil { - startup.MigrateGitOpsSyncIntervals( - appCtx, - appServices.GitOpsSync.ListSyncIntervalsRaw, - appServices.GitOpsSync.UpdateSyncIntervalMinutes, - ) if err := appServices.GitOpsSync.ReconcileDirectorySyncProjectsOnStartup(appCtx); err != nil { slog.WarnContext(appCtx, "Failed to reconcile directory GitOps projects on startup", "error", err) } @@ -197,6 +195,7 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi } startup.InitializeNonAgentFeatures(appCtx, runtimeCfg, + appServices.Role.EnsureBuiltInRoles, appServices.User.CreateDefaultAdmin, func(ctx context.Context) error { return appServices.ApiKey.ReconcileDefaultAdminAPIKey(ctx, runtimeCfg.AdminStaticAPIKey) @@ -205,11 +204,11 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi startup.InitializeAutoLogin(ctx, runtimeCfg) return nil }, - appServices.Settings.MigrateOidcConfigToFields, - appServices.Notification.MigrateDiscordWebhookUrlToFields, ) startup.CleanupUnknownSettings(appCtx, appServices.Settings) + runRoleStartupTasks(appCtx, appServices.Role, cfg, cfg.AgentMode) + // Auto-pair only applies in Edge mode (where the agent's outbound tunnel is the // only path to the manager). Direct mode is passive — the manager dials the agent's // HTTP server on TCP 3553, and the manager-side health-check promotes the env to @@ -223,6 +222,35 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi } } +func runRoleStartupTasks(ctx context.Context, roleService *services.RoleService, cfg *config.Config, agentMode bool) { + if roleService == nil { + return + } + if err := roleService.EnsureBuiltInRoles(ctx); err != nil { + slog.ErrorContext(ctx, "Failed to reconcile built-in roles", "error", err) + } + // Backfill must run AFTER EnsureBuiltInRoles (it references the role IDs + // seeded there) and BEFORE BackfillApiKeyPermissions / AssertGlobalAdminExists + // (both consult the assignments table this populates). + if err := roleService.BackfillLegacyRoleAssignments(ctx); err != nil { + slog.ErrorContext(ctx, "Failed to backfill legacy users.roles into user_role_assignments", "error", err) + } + if err := roleService.BackfillApiKeyPermissions(ctx); err != nil { + slog.WarnContext(ctx, "Failed to backfill API key permissions", "error", err) + } + if cfg != nil { + if err := roleService.ReconcileEnvOidcMappings(ctx, cfg.OidcRoleMappings); err != nil { + slog.ErrorContext(ctx, "Failed to reconcile OIDC_ROLE_MAPPINGS", "error", err) + } + } + if agentMode { + return + } + if err := roleService.AssertGlobalAdminExists(ctx); err != nil { + slog.ErrorContext(ctx, "RBAC global admin guard failed", "error", err) + } +} + func startEdgeTunnelClientIfConfigured(appCtx context.Context, cfg *config.Config, router http.Handler) { managerEndpointConfigured := cfg.ManagerApiUrl != "" if !cfg.EdgeAgent || !managerEndpointConfigured || cfg.AgentToken == "" { @@ -274,7 +302,7 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl return fmt.Errorf("failed to create pairing request: %w", err) } - req.Header.Set("X-API-Key", cfg.AgentToken) + req.Header.Set("X-Api-Key", cfg.AgentToken) if cfg.EdgeAgent && strings.TrimSpace(cfg.ManagerApiUrl) != "" { edgeClient, edgeErr := edge.NewManagerHTTPClient(&edge.Config{ @@ -292,7 +320,7 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl httpClient = edgeClient } - resp, err := httpClient.Do(req) //nolint:gosec // intentional request to configured manager pairing endpoint + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("pairing request failed: %w", err) } @@ -321,7 +349,9 @@ func handleAgentBootstrapPairing(ctx context.Context, cfg *config.Config, httpCl } } -func runServices(appCtx context.Context, cfg *config.Config, router http.Handler, tunnelServer *edge.TunnelServer, schedulers ...interface{ Run(context.Context) error }) error { +func runServicesInternal(appCtx context.Context, cfg *config.Config, router http.Handler, tunnelServer *edge.TunnelServer, schedulers ...interface { + Run(ctx context.Context) error +}) error { for _, s := range schedulers { scheduler := s go func() { @@ -378,7 +408,7 @@ func runServices(appCtx context.Context, cfg *config.Config, router http.Handler } // Use background context for shutdown as appCtx is already canceled - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) //nolint:contextcheck + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck @@ -405,7 +435,7 @@ func prepareServerTLSInternal(ctx context.Context, cfg *config.Config) (bool, st tlsKeyFile := strings.TrimSpace(cfg.TLSKeyFile) edgeCfg := buildEdgeRuntimeConfigInternal(cfg) if useTLS && (tlsCertFile == "" || tlsKeyFile == "") { - return false, "", "", nil, fmt.Errorf("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE") + return false, "", "", nil, errors.New("TLS_ENABLED requires both TLS_CERT_FILE and TLS_KEY_FILE") } if cfg.AgentMode { @@ -514,8 +544,9 @@ func normalizeTunnelGRPCRequestPathInternal(r *http.Request) *http.Request { } connectMethodPath := tunnelpb.TunnelService_Connect_FullMethodName - legacyAPIPath := "/api/tunnel/connect" - if strings.HasSuffix(r.URL.Path, legacyAPIPath) { + + const tunnelConnectPath = "/api/tunnel/connect" + if strings.HasSuffix(r.URL.Path, tunnelConnectPath) { clone := r.Clone(r.Context()) cloneURL := *clone.URL cloneURL.Path = connectMethodPath @@ -553,7 +584,7 @@ func isTunnelGRPCRequestInternal(r *http.Request) bool { path := r.URL.Path fullMethodPath := tunnelpb.TunnelService_Connect_FullMethodName - if path == fullMethodPath || strings.HasSuffix(path, fullMethodPath) || strings.HasSuffix(path, "/api/tunnel/connect") { + if path == fullMethodPath || strings.HasSuffix(path, fullMethodPath) { return true } diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go index 1d883bf210..5924efc2d3 100644 --- a/backend/internal/bootstrap/bootstrap_test.go +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -12,6 +12,8 @@ import ( "time" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" + "github.com/getarcaneapp/arcane/backend/internal/middleware" libcrypto "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" tunnelpb "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge/proto/tunnel/v1" "github.com/stretchr/testify/assert" @@ -43,8 +45,8 @@ func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) { assert.Equal(t, fullMethodPath, normalized.RequestURI) }) - t.Run("legacy api tunnel path maps to grpc method", func(t *testing.T) { - req := httptest.NewRequest("POST", "/api/tunnel/connect", nil) + t.Run("nested proxy prefix is removed up to method path", func(t *testing.T) { + req := httptest.NewRequest("POST", "/edge/proxy/api"+fullMethodPath, nil) normalized := normalizeTunnelGRPCRequestPathInternal(req) assert.NotSame(t, req, normalized) @@ -52,8 +54,12 @@ func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) { assert.Equal(t, fullMethodPath, normalized.RequestURI) }) - t.Run("prefixed legacy api tunnel path maps to grpc method", func(t *testing.T) { - req := httptest.NewRequest("POST", "/edge/proxy/api/tunnel/connect", nil) + t.Run("legacy /api/tunnel/connect is rewritten to gRPC method", func(t *testing.T) { + // Regression: PR #2722 removed this branch, breaking the edge agent's + // gRPC transport. The agent client uses /api/tunnel/connect as its + // gRPC method path so reverse proxies can route tunnel traffic with + // a stable URL instead of the proto-generated gRPC service name. + req := httptest.NewRequest("POST", "/api/tunnel/connect", nil) normalized := normalizeTunnelGRPCRequestPathInternal(req) assert.NotSame(t, req, normalized) @@ -61,8 +67,8 @@ func TestNormalizeTunnelGRPCRequestPathInternal(t *testing.T) { assert.Equal(t, fullMethodPath, normalized.RequestURI) }) - t.Run("nested proxy prefix is removed up to method path", func(t *testing.T) { - req := httptest.NewRequest("POST", "/edge/proxy/api"+fullMethodPath, nil) + t.Run("nested proxy with legacy /api/tunnel/connect is rewritten", func(t *testing.T) { + req := httptest.NewRequest("POST", "/edge/proxy/api/tunnel/connect", nil) normalized := normalizeTunnelGRPCRequestPathInternal(req) assert.NotSame(t, req, normalized) @@ -91,11 +97,6 @@ func TestIsTunnelGRPCRequestInternal(t *testing.T) { assert.True(t, isTunnelGRPCRequestInternal(req)) }) - t.Run("detects by legacy tunnel path", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/tunnel/connect", nil) - assert.True(t, isTunnelGRPCRequestInternal(req)) - }) - t.Run("does not match regular api requests", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/environments/pair", nil) req.Header.Set("Content-Type", "application/json") @@ -150,10 +151,13 @@ func TestConfigureHTTPProtocolsInternal(t *testing.T) { } func TestHTTP2APIResponsesDoNotUseAPIGzipInternal(t *testing.T) { - router, _ := setupRouter(context.Background(), &config.Config{ + cfg := &config.Config{ AppUrl: "http://localhost:3552", Environment: config.AppEnvironmentTest, - }, &Services{}) + } + router, _ := setupRouter(context.Background(), cfg, &di.Services{ + AuthMiddleware: middleware.NewAuthMiddleware(nil, cfg), + }) handler, protocols := configureHTTPProtocolsInternal(false, router) listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/backend/internal/bootstrap/buildables_router_bootstrap.go b/backend/internal/bootstrap/buildables_router_bootstrap.go index a1d3380526..082c09165d 100644 --- a/backend/internal/bootstrap/buildables_router_bootstrap.go +++ b/backend/internal/bootstrap/buildables_router_bootstrap.go @@ -4,11 +4,12 @@ package bootstrap import ( "github.com/getarcaneapp/arcane/backend/api" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/labstack/echo/v4" ) func init() { - registerBuildableRoutes = append(registerBuildableRoutes, func(apiGroup *echo.Group, svc *Services) { + registerBuildableRoutes = append(registerBuildableRoutes, func(apiGroup *echo.Group, svc *di.Services) { api.SetupBuildablesRoutes(apiGroup, svc.Auth) }) } diff --git a/backend/internal/bootstrap/edge_bootstrap.go b/backend/internal/bootstrap/edge_bootstrap.go index 8d85672b82..f8493cfa6c 100644 --- a/backend/internal/bootstrap/edge_bootstrap.go +++ b/backend/internal/bootstrap/edge_bootstrap.go @@ -3,10 +3,12 @@ package bootstrap import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/middleware" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" @@ -21,7 +23,7 @@ func registerEdgeTunnelRoutes( ctx context.Context, cfg *config.Config, apiGroup *echo.Group, - appServices *Services, + appServices *di.Services, ) *edge.TunnelServer { // Resolver that validates API key and returns the environment ID resolver := func(ctx context.Context, token string) (string, error) { @@ -51,7 +53,7 @@ func registerEdgeTunnelRoutes( eventCallback := func(ctx context.Context, envID string, evt *edge.TunnelEvent) error { if evt == nil { - return fmt.Errorf("event payload is required") + return errors.New("event payload is required") } var metadata models.JSON @@ -112,15 +114,14 @@ func registerEdgeTunnelRoutes( if env, err := appServices.Environment.GetEnvironmentByID(ctx, envID); err == nil && env != nil { envName = env.Name } - resourceType := "environment" envIDCopy := envID envNameCopy := envName _, _ = appServices.Event.CreateEvent(ctx, services.CreateEventRequest{ Type: models.EventTypeEnvironmentMTLSEnroll, Severity: edgeMTLSEnrollmentSeverityInternal(reenrolled), Title: "Edge mTLS enrollment", - Description: fmt.Sprintf("Edge agent completed mTLS enrollment from %s", remoteAddr), - ResourceType: &resourceType, + Description: "Edge agent completed mTLS enrollment from " + remoteAddr, + ResourceType: new("environment"), ResourceID: &envIDCopy, ResourceName: &envNameCopy, EnvironmentID: &envIDCopy, @@ -157,13 +158,12 @@ func createEdgeMTLSIssueEventsInternal(ctx context.Context, eventService *servic }) } if certIssued { - resourceType := "environment" _, _ = eventService.CreateEvent(ctx, services.CreateEventRequest{ Type: models.EventTypeEnvironmentMTLSCertIssued, Severity: edgeMTLSCertIssuedSeverityInternal(reenrolled), Title: "Edge mTLS certificate issued", Description: fmt.Sprintf("Arcane issued an edge mTLS client certificate for environment '%s'", envName), - ResourceType: &resourceType, + ResourceType: new("environment"), ResourceID: &envID, ResourceName: &envName, EnvironmentID: &envID, diff --git a/backend/internal/bootstrap/jobs_bootstrap.go b/backend/internal/bootstrap/jobs_bootstrap.go index 429a1c3ceb..c709242fd6 100644 --- a/backend/internal/bootstrap/jobs_bootstrap.go +++ b/backend/internal/bootstrap/jobs_bootstrap.go @@ -7,102 +7,83 @@ import ( "net/http" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" pkg_scheduler "github.com/getarcaneapp/arcane/backend/pkg/scheduler" ) -func registerJobs(appCtx context.Context, newScheduler *pkg_scheduler.JobScheduler, appServices *Services, appConfig *config.Config) { - autoUpdateJob := pkg_scheduler.NewAutoUpdateJob(appServices.Updater, appServices.Settings) - newScheduler.RegisterJob(autoUpdateJob) +func registerJobs(appCtx context.Context, newScheduler *pkg_scheduler.JobScheduler, appServices *di.Services, appConfig *config.Config) { + // wire constructs every job from the built services; bootstrap owns registration, + // the agent-mode gating, the startup heartbeat, and the settings callbacks. + jobs := di.InitializeJobs(appCtx, appConfig, appServices) - imagePollingJob := pkg_scheduler.NewImagePollingJob(appServices.ImageUpdate, appServices.Settings, appServices.Environment) - newScheduler.RegisterJob(imagePollingJob) - - environmentHealthJob := pkg_scheduler.NewEnvironmentHealthJob(appServices.Environment, appServices.Settings) + newScheduler.RegisterJob(jobs.AutoUpdate) + newScheduler.RegisterJob(jobs.ImagePolling) if !appConfig.AgentMode { - newScheduler.RegisterJob(environmentHealthJob) + newScheduler.RegisterJob(jobs.EnvironmentHealth) } - - dockerClientRefreshJob := pkg_scheduler.NewDockerClientRefreshJob(appServices.Docker, appServices.Settings) - newScheduler.RegisterJob(dockerClientRefreshJob) - - analyticsJob := pkg_scheduler.NewAnalyticsJob(appServices.Settings, appServices.KV, nil, appConfig) - newScheduler.RegisterJob(analyticsJob) + newScheduler.RegisterJob(jobs.DockerClientRefresh) + newScheduler.RegisterJob(jobs.Analytics) // Send initial heartbeat on startup without blocking bootstrap. - go analyticsJob.Run(appCtx) - - eventCleanupJob := pkg_scheduler.NewEventCleanupJob(appServices.Event, appServices.Settings) - newScheduler.RegisterJob(eventCleanupJob) - - expiredSessionsCleanupJob := pkg_scheduler.NewExpiredSessionsCleanupJob(appServices.Session, appServices.Settings) - newScheduler.RegisterJob(expiredSessionsCleanupJob) - - scheduledPruneJob := pkg_scheduler.NewScheduledPruneJob(appServices.System, appServices.Settings, appServices.Notification) - newScheduler.RegisterJob(scheduledPruneJob) - - fsWatcherJob, err := pkg_scheduler.RegisterFilesystemWatcherJob(appCtx, appServices.Project, appServices.Template, appServices.Settings, appConfig.ProjectScanMaxDepth) - if err != nil { - slog.ErrorContext(appCtx, "Failed to register filesystem watcher job", "error", err) - } - - gitOpsSyncJob := pkg_scheduler.NewGitOpsSyncJob(appServices.GitOpsSync, appServices.Settings) - newScheduler.RegisterJob(gitOpsSyncJob) - - vulnerabilityScanJob := pkg_scheduler.NewVulnerabilityScanJob(appServices.Vulnerability, appServices.Settings) - newScheduler.RegisterJob(vulnerabilityScanJob) - - autoHealJob := pkg_scheduler.NewAutoHealJob(appServices.Docker, appServices.Settings, appServices.Event, appServices.Notification) - newScheduler.RegisterJob(autoHealJob) - - setupSettingsCallbacks(appCtx, appServices, appConfig, newScheduler, imagePollingJob, autoUpdateJob, environmentHealthJob, fsWatcherJob, scheduledPruneJob, vulnerabilityScanJob, autoHealJob) + go jobs.Analytics.Run(appCtx) + newScheduler.RegisterJob(jobs.EventCleanup) + newScheduler.RegisterJob(jobs.ExpiredSessionsCleanup) + newScheduler.RegisterJob(jobs.ScheduledPrune) + // FilesystemWatcher is intentionally not scheduler-registered; it watches inline + // and is only rebound on settings changes below. + newScheduler.RegisterJob(jobs.GitOpsSync) + newScheduler.RegisterJob(jobs.VulnerabilityScan) + newScheduler.RegisterJob(jobs.AutoHeal) + + setupSettingsCallbacks(appCtx, appServices, appConfig, newScheduler, jobs) } -func setupSettingsCallbacks(lifecycleCtx context.Context, appServices *Services, appConfig *config.Config, newScheduler *pkg_scheduler.JobScheduler, imagePollingJob *pkg_scheduler.ImagePollingJob, autoUpdateJob *pkg_scheduler.AutoUpdateJob, environmentHealthJob *pkg_scheduler.EnvironmentHealthJob, fsWatcherJob *pkg_scheduler.FilesystemWatcherJob, scheduledPruneJob *pkg_scheduler.ScheduledPruneJob, vulnerabilityScanJob *pkg_scheduler.VulnerabilityScanJob, autoHealJob *pkg_scheduler.AutoHealJob) { +func setupSettingsCallbacks(lifecycleCtx context.Context, appServices *di.Services, appConfig *config.Config, newScheduler *pkg_scheduler.JobScheduler, jobs *di.Jobs) { appServices.Settings.OnImagePollingSettingsChanged = func(_ context.Context) { - if err := newScheduler.RescheduleJob(lifecycleCtx, imagePollingJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.ImagePolling); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule image-polling job", "error", err) } - if err := newScheduler.RescheduleJob(lifecycleCtx, autoUpdateJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.AutoUpdate); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule auto-update job", "error", err) } if !appConfig.AgentMode { - if err := newScheduler.RescheduleJob(lifecycleCtx, environmentHealthJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.EnvironmentHealth); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule environment-health job", "error", err) } } } appServices.Settings.OnAutoUpdateSettingsChanged = func(ctx context.Context) { slog.DebugContext(lifecycleCtx, "AutoUpdateSettingsChanged callback triggered", "triggerContextCanceled", ctx.Err() != nil) - if err := newScheduler.RescheduleJob(lifecycleCtx, autoUpdateJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.AutoUpdate); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule auto-update job", "error", err) } } appServices.Settings.OnProjectsDirectoryChanged = func(_ context.Context) { - if fsWatcherJob != nil { - if err := fsWatcherJob.RestartProjectsWatcher(lifecycleCtx); err != nil { + if jobs.FilesystemWatcher != nil { + if err := jobs.FilesystemWatcher.RestartProjectsWatcher(lifecycleCtx); err != nil { slog.WarnContext(lifecycleCtx, "Failed to restart projects filesystem watcher", "error", err) } } } appServices.Settings.OnTemplatesDirectoryChanged = func(_ context.Context) { - if fsWatcherJob != nil { - if err := fsWatcherJob.RestartTemplatesWatcher(lifecycleCtx); err != nil { + if jobs.FilesystemWatcher != nil { + if err := jobs.FilesystemWatcher.RestartTemplatesWatcher(lifecycleCtx); err != nil { slog.WarnContext(lifecycleCtx, "Failed to restart templates filesystem watcher", "error", err) } } } appServices.Settings.OnScheduledPruneSettingsChanged = func(_ context.Context) { - if err := newScheduler.RescheduleJob(lifecycleCtx, scheduledPruneJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.ScheduledPrune); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule scheduled-prune job", "error", err) } } appServices.Settings.OnVulnerabilityScanSettingsChanged = func(_ context.Context) { - if err := newScheduler.RescheduleJob(lifecycleCtx, vulnerabilityScanJob); err != nil { + if err := newScheduler.RescheduleJob(lifecycleCtx, jobs.VulnerabilityScan); err != nil { slog.WarnContext(lifecycleCtx, "Failed to reschedule vulnerability-scan job", "error", err) } } appServices.Settings.OnAutoHealSettingsChanged = func(ctx context.Context) { - if err := newScheduler.RescheduleJob(ctx, autoHealJob); err != nil { + if err := newScheduler.RescheduleJob(ctx, jobs.AutoHeal); err != nil { slog.WarnContext(ctx, "Failed to reschedule auto-heal job", "error", err) } } @@ -116,7 +97,7 @@ func setupSettingsCallbacks(lifecycleCtx context.Context, appServices *Services, } // syncTimeoutSettingsToAgentsInternal syncs timeout settings to all connected remote environments -func syncTimeoutSettingsToAgentsInternal(ctx context.Context, appServices *Services, timeoutSettings []libarcane.SettingUpdate) { +func syncTimeoutSettingsToAgentsInternal(ctx context.Context, appServices *di.Services, timeoutSettings []libarcane.SettingUpdate) { envs, err := appServices.Environment.ListRemoteEnvironments(ctx) if err != nil { slog.WarnContext(ctx, "Failed to list remote environments for timeout sync", "error", err) diff --git a/backend/internal/bootstrap/playwright_router_bootstrap.go b/backend/internal/bootstrap/playwright_router_bootstrap.go index 57629c65bc..a72ac53870 100644 --- a/backend/internal/bootstrap/playwright_router_bootstrap.go +++ b/backend/internal/bootstrap/playwright_router_bootstrap.go @@ -6,14 +6,15 @@ import ( "log/slog" "github.com/getarcaneapp/arcane/backend/api" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/labstack/echo/v4" ) func init() { - registerPlaywrightRoutes = []func(apiGroup *echo.Group, services *Services){ - func(apiGroup *echo.Group, svc *Services) { - playwrightService := services.NewPlaywrightService(svc.ApiKey, svc.User) + registerPlaywrightRoutes = []func(apiGroup *echo.Group, services *di.Services){ + func(apiGroup *echo.Group, svc *di.Services) { + playwrightService := services.NewPlaywrightService(svc.ApiKey, svc.User, svc.Federated) if playwrightService == nil { slog.Warn("Playwright service not available, skipping playwright routes") return diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 952e41d0f0..fcc18970b1 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -5,7 +5,6 @@ import ( "log/slog" "net" "net/http" - "net/http/pprof" "path" "strings" @@ -14,9 +13,11 @@ import ( slogecho "github.com/samber/slog-echo" "github.com/getarcaneapp/arcane/backend/api" + "github.com/getarcaneapp/arcane/backend/api/handlers" "github.com/getarcaneapp/arcane/backend/api/ws" "github.com/getarcaneapp/arcane/backend/frontend" "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/di" "github.com/getarcaneapp/arcane/backend/internal/middleware" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/edge" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" @@ -24,8 +25,8 @@ import ( ) var ( - registerPlaywrightRoutes []func(apiGroup *echo.Group, services *Services) - registerBuildableRoutes []func(apiGroup *echo.Group, services *Services) + registerPlaywrightRoutes []func(apiGroup *echo.Group, services *di.Services) + registerBuildableRoutes []func(apiGroup *echo.Group, services *di.Services) ) var loggerSkipPatterns = []string{ @@ -37,10 +38,11 @@ var loggerSkipPatterns = []string{ "GET /api/environments/*/ws/system/stats", "GET /_app/*", "GET /img", - "GET /api/fonts/sans", - "GET /api/fonts/mono", "GET /api/health", "HEAD /api/health", + // Static branding / PWA assets — browsers re-request these frequently + // and the logs add no signal. + "GET /api/app-images/*", } func shouldLogRequestInternal(c echo.Context) bool { @@ -84,11 +86,11 @@ func requestLoggerMiddlewareInternal() echo.MiddlewareFunc { } } -func createAuthValidatorInternal(appServices *Services) middleware.AuthValidator { +func createAuthValidatorInternal(appServices *di.Services) middleware.AuthValidator { return func(ctx context.Context, c echo.Context) bool { req := c.Request() // Check for API key authentication - if apiKey := req.Header.Get("X-API-Key"); apiKey != "" { + if apiKey := req.Header.Get("X-Api-Key"); apiKey != "" { // User-owned API key if user, err := appServices.ApiKey.ValidateApiKey(ctx, apiKey); err == nil && user != nil { return true @@ -118,7 +120,7 @@ func createAuthValidatorInternal(appServices *Services) middleware.AuthValidator } } -func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) (*echo.Echo, *edge.TunnelServer) { +func setupRouter(ctx context.Context, cfg *config.Config, appServices *di.Services) (*echo.Echo, *edge.TunnelServer) { e := echo.New() e.HideBanner = true e.HidePort = true @@ -141,10 +143,9 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) e.Use(requestLoggerMiddlewareInternal()) //nolint:contextcheck e.Use(secureCookieContextMiddlewareInternal(trustedProxyNets)) //nolint:contextcheck - authMiddleware := middleware.NewAuthMiddleware(appServices.Auth, cfg). - WithApiKeyValidator(appServices.ApiKey). - WithEnvironmentAccessTokenResolver(appServices.Environment) + authMiddleware := appServices.AuthMiddleware e.Use(middleware.NewCORSMiddleware(cfg).Add()) + e.Use(middleware.NewCSRFMiddleware(cfg).Add()) //nolint:contextcheck // Echo middleware uses request context from echo.Context, not the app lifecycle context. apiGroup := e.Group("/api") @@ -155,9 +156,13 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) "/api/oidc/callback", }, 5, 5, )) + apiGroup.Use(middleware.PerIPRateLimitForPaths( + []string{"/api/auth/federated/token"}, 10, 10, + )) apiGroup.Use(middleware.PerIPRateLimitForPaths( []string{"/api/webhooks/trigger/:token"}, 60, 10, )) + handlerAppCtx := handlers.NewActivityAppContext(ctx) tunnelRegistry := edge.NewTunnelRegistry() edge.SetDefaultRegistry(tunnelRegistry) @@ -170,7 +175,8 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) } // Register public webhook trigger endpoint before auth middleware (token in URL is the sole auth) - api.RegisterWebhookTrigger(apiGroup, appServices.Webhook) //nolint:contextcheck + api.RegisterWebhookTrigger(apiGroup, appServices.Webhook, handlerAppCtx) //nolint:contextcheck // app lifecycle context is intentionally wrapped for detached activity work. + handlers.RegisterFederatedTokenExchange(apiGroup, appServices.Federated) //nolint:contextcheck // public RFC 8693 form endpoint uses request context. //nolint:contextcheck // Echo middleware reads context from echo.Context.Request().Context(), not a parameter. envProxyMiddleware := middleware.NewEnvProxyMiddlewareWithParam( @@ -181,57 +187,14 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services) ) apiGroup.Use(envProxyMiddleware) - humaServices := &api.Services{ - User: appServices.User, - Auth: appServices.Auth, - Oidc: appServices.Oidc, - ApiKey: appServices.ApiKey, - AppImages: appServices.AppImages, - Font: appServices.Font, - Project: appServices.Project, - Event: appServices.Event, - Version: appServices.Version, - Environment: appServices.Environment, - Settings: appServices.Settings, - JobSchedule: appServices.JobSchedule, - SettingsSearch: appServices.SettingsSearch, - ContainerRegistry: appServices.ContainerRegistry, - Template: appServices.Template, - Docker: appServices.Docker, - Image: appServices.Image, - ImageUpdate: appServices.ImageUpdate, - Build: appServices.Build, - BuildWorkspace: appServices.BuildWorkspace, - Volume: appServices.Volume, - Container: appServices.Container, - Network: appServices.Network, - Port: appServices.Port, - Swarm: appServices.Swarm, - Notification: appServices.Notification, - Apprise: appServices.Apprise, - Updater: appServices.Updater, - CustomizeSearch: appServices.CustomizeSearch, - System: appServices.System, - SystemUpgrade: appServices.SystemUpgrade, - GitRepository: appServices.GitRepository, - GitOpsSync: appServices.GitOpsSync, - Webhook: appServices.Webhook, - Vulnerability: appServices.Vulnerability, - Dashboard: appServices.Dashboard, - Config: cfg, - } - - _ = api.SetupAPI(e, apiGroup, cfg, humaServices) + _ = api.SetupAPI(e, apiGroup, handlerAppCtx, cfg, appServices) //nolint:contextcheck // app lifecycle context is intentionally wrapped for detached activity work. for _, register := range registerBuildableRoutes { register(apiGroup, appServices) } - api.RegisterDiagnosticsRoutes(apiGroup, authMiddleware, ws.DefaultWebSocketMetrics()) //nolint:contextcheck - registerPprofRoutesInternal(apiGroup, authMiddleware) //nolint:contextcheck - // Remaining echo handlers (WebSocket/streaming) - ws.NewWebSocketHandler(apiGroup, appServices.Project, appServices.Container, appServices.Swarm, appServices.System, authMiddleware, cfg) //nolint:contextcheck + ws.NewWebSocketHandler(apiGroup, appServices.Project, appServices.Container, appServices.Swarm, appServices.System, appServices.Diagnostics, authMiddleware, cfg) //nolint:contextcheck // Register edge tunnel endpoint for manager to accept agent connections // This is only registered when NOT in agent mode (i.e., running as manager) @@ -260,7 +223,7 @@ func parseTrustedProxyCIDRsInternal(raw string) []*net.IPNet { return nil } var nets []*net.IPNet - for _, cidr := range strings.Split(raw, ",") { + for cidr := range strings.SplitSeq(raw, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue @@ -317,15 +280,3 @@ func remoteAddrInTrustedProxiesInternal(remoteAddr string, nets []*net.IPNet) bo } return false } - -func registerPprofRoutesInternal(apiGroup *echo.Group, authMiddleware *middleware.AuthMiddleware) { - pprofGroup := apiGroup.Group("/debug/pprof", authMiddleware.WithAdminRequired().Add()) - pprofGroup.GET("", echo.WrapHandler(http.HandlerFunc(pprof.Index))) - pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index))) - pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) - pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) - pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) - pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) - pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace))) - pprofGroup.GET("/:name", echo.WrapHandler(http.HandlerFunc(pprof.Index))) -} diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go deleted file mode 100644 index 6928ea2d99..0000000000 --- a/backend/internal/bootstrap/services_bootstrap.go +++ /dev/null @@ -1,114 +0,0 @@ -package bootstrap - -import ( - "context" - "fmt" - "net/http" - - "github.com/getarcaneapp/arcane/backend/internal/config" - "github.com/getarcaneapp/arcane/backend/internal/database" - "github.com/getarcaneapp/arcane/backend/internal/services" - "github.com/getarcaneapp/arcane/backend/resources" -) - -type Services struct { - AppImages *services.ApplicationImagesService - User *services.UserService - Project *services.ProjectService - Environment *services.EnvironmentService - Settings *services.SettingsService - KV *services.KVService - JobSchedule *services.JobService - SettingsSearch *services.SettingsSearchService - CustomizeSearch *services.CustomizeSearchService - Container *services.ContainerService - Image *services.ImageService - Build *services.BuildService - BuildWorkspace *services.BuildWorkspaceService - Volume *services.VolumeService - Network *services.NetworkService - Port *services.PortService - Swarm *services.SwarmService - ImageUpdate *services.ImageUpdateService - Session *services.SessionService - Auth *services.AuthService - Oidc *services.OidcService - Docker *services.DockerClientService - Template *services.TemplateService - ContainerRegistry *services.ContainerRegistryService - System *services.SystemService - SystemUpgrade *services.SystemUpgradeService - Updater *services.UpdaterService - Event *services.EventService - Version *services.VersionService - Notification *services.NotificationService - Apprise *services.AppriseService //nolint:staticcheck // Apprise still functional, deprecated in favor of Shoutrrr - ApiKey *services.ApiKeyService - GitRepository *services.GitRepositoryService - GitOpsSync *services.GitOpsSyncService - Webhook *services.WebhookService - Font *services.FontService - Vulnerability *services.VulnerabilityService - Dashboard *services.DashboardService -} - -func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (svcs *Services, dockerSrvice *services.DockerClientService, err error) { - svcs = &Services{} - - svcs.Event = services.NewEventService(db, cfg, httpClient) - svcs.Settings, err = services.NewSettingsService(ctx, db) - if err != nil { - return nil, nil, fmt.Errorf("failed to settings service: %w", err) - } - svcs.KV = services.NewKVService(db) - svcs.JobSchedule = services.NewJobService(db, svcs.Settings, cfg) - svcs.SettingsSearch = services.NewSettingsSearchService() - svcs.CustomizeSearch = services.NewCustomizeSearchService() - svcs.AppImages = services.NewApplicationImagesService(resources.FS, svcs.Settings) - svcs.Font = services.NewFontService(resources.FS) - dockerClient := services.NewDockerClientService(ctx, db, cfg, svcs.Settings) - svcs.Docker = dockerClient - svcs.User = services.NewUserService(db) - svcs.Session = services.NewSessionService(db) - svcs.ApiKey = services.NewApiKeyService(db, svcs.User) - svcs.ContainerRegistry = services.NewContainerRegistryService(db, func(ctx context.Context) (services.RegistryDaemonClient, error) { - return dockerClient.GetClient(ctx) - }, svcs.KV) - svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event, svcs.Settings, svcs.ApiKey) - svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker) - svcs.Notification = services.NewNotificationService(db, cfg, svcs.Environment) - svcs.Apprise = services.NewAppriseService(db, cfg) - svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification) - svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification) - svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Vulnerability, svcs.Event) - svcs.GitRepository = services.NewGitRepositoryService(db, cfg.GitWorkDir, svcs.Event, svcs.Settings) - svcs.Build = services.NewBuildService(db, svcs.Settings, svcs.Docker, svcs.ContainerRegistry, svcs.GitRepository, svcs.Event) - svcs.BuildWorkspace = services.NewBuildWorkspaceService(svcs.Settings) - svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image, svcs.Docker, svcs.Build, cfg) - svcs.Container = services.NewContainerService(ctx, db, svcs.Event, svcs.Docker, svcs.Image, svcs.Settings, svcs.Project) - svcs.Dashboard = services.NewDashboardService( - db, - svcs.Docker, - svcs.Container, - svcs.Project, - svcs.Image, - svcs.Settings, - svcs.Vulnerability, - svcs.Environment, - svcs.Version, - ) - svcs.Volume = services.NewVolumeService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Container, svcs.Image, cfg.BackupVolumeName) - svcs.Network = services.NewNetworkService(db, svcs.Docker, svcs.Event) - svcs.Port = services.NewPortService(svcs.Docker) - svcs.Swarm = services.NewSwarmService(svcs.Docker, svcs.Settings, svcs.KV, svcs.ContainerRegistry, svcs.Environment) - svcs.Template = services.NewTemplateService(ctx, db, httpClient, svcs.Settings) - svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, svcs.Session, cfg.JWTSecret, cfg) - svcs.Oidc = services.NewOidcService(svcs.Auth, svcs.Settings, cfg, httpClient) - svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings) - svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings) - svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade) - svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Swarm, svcs.Event, svcs.Settings) - svcs.Webhook = services.NewWebhookService(db, svcs.Container, svcs.Updater, svcs.Project, svcs.GitOpsSync, svcs.Event) - - return svcs, dockerClient, nil -} diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 1cc554184c..88532b0ce7 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -423,14 +423,6 @@ func (e *AgentTokenPersistenceError) Error() string { return "Failed to persist agent token" } -type AgentPairingError struct { - Err error -} - -func (e *AgentPairingError) Error() string { - return fmt.Sprintf("Agent pairing failed: %v", e.Err) -} - type EnvironmentCreationError struct { Err error } @@ -733,28 +725,6 @@ func (e *NotificationTestError) Error() string { return fmt.Sprintf("Failed to send test notification: %v", e.Err) } -type AppriseSettingsNotFoundError struct{} - -func (e *AppriseSettingsNotFoundError) Error() string { - return "Apprise settings not found" -} - -type AppriseSettingsUpdateError struct { - Err error -} - -func (e *AppriseSettingsUpdateError) Error() string { - return fmt.Sprintf("Failed to update Apprise settings: %v", e.Err) -} - -type AppriseTestError struct { - Err error -} - -func (e *AppriseTestError) Error() string { - return fmt.Sprintf("Failed to send Apprise test notification: %v", e.Err) -} - type OidcStatusError struct { Err error } @@ -1006,14 +976,6 @@ func (e *DockerInfoError) Error() string { return fmt.Sprintf("Failed to get Docker info: %v", e.Err) } -type SystemPruneError struct { - Err error -} - -func (e *SystemPruneError) Error() string { - return fmt.Sprintf("Failed to prune resources: %v", e.Err) -} - type ContainerStartAllError struct { Err error } @@ -1502,7 +1464,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 { @@ -1518,7 +1480,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 { @@ -1561,6 +1523,23 @@ 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 { + return "redeploy failed: " + e.Err.Error() +} + +func (e *RedeployAfterSyncFailedError) Unwrap() error { + return e.Err +} + type VulnerabilityScanError struct { Err error } @@ -1732,3 +1711,201 @@ func (e *BuildKitDockerExporterError) Error() string { } func (e *BuildKitDockerExporterError) Unwrap() error { return e.Err } + +// ----- RBAC / role service errors ----- + +type RoleNotFoundError struct{} + +func (e *RoleNotFoundError) Error() string { + return "Role not found" +} + +func IsRoleNotFoundError(err error) bool { + return isErrorTypeInternal[*RoleNotFoundError](err) +} + +type RoleBuiltInError struct{} + +func (e *RoleBuiltInError) Error() string { + return "Built-in role cannot be modified" +} + +func IsRoleBuiltInError(err error) bool { + return isErrorTypeInternal[*RoleBuiltInError](err) +} + +type RoleNameTakenError struct{} + +func (e *RoleNameTakenError) Error() string { + return "Role name already in use" +} + +func IsRoleNameTakenError(err error) bool { + return isErrorTypeInternal[*RoleNameTakenError](err) +} + +type UnknownPermissionError struct { + Perm string +} + +func (e *UnknownPermissionError) Error() string { + return "Unknown permission: " + e.Perm +} + +func IsUnknownPermissionError(err error) bool { + return isErrorTypeInternal[*UnknownPermissionError](err) +} + +// RolePermissionEscalationError is returned when a caller attempts to author a +// role containing a permission they do not themselves hold at global scope. +// Used by the CreateRole and UpdateRole handlers as defense-in-depth alongside +// the RequireGlobalAdmin middleware. +type RolePermissionEscalationError struct { + Perm string +} + +func (e *RolePermissionEscalationError) Error() string { + return "cannot grant a permission you do not hold: " + e.Perm +} + +func IsRolePermissionEscalationError(err error) bool { + return isErrorTypeInternal[*RolePermissionEscalationError](err) +} + +// InvalidRoleAssignmentError is returned when SetUserAssignments is called with +// a RoleID or EnvironmentID that doesn't exist in the database. Surfaces as a +// 400 Bad Request so callers see a descriptive message instead of an opaque +// FK-violation 500 from the underlying tx.Create. +type InvalidRoleAssignmentError struct { + RoleID string + EnvironmentID string +} + +func (e *InvalidRoleAssignmentError) Error() string { + if e.RoleID != "" { + return fmt.Sprintf("invalid role assignment: role %q does not exist", e.RoleID) + } + if e.EnvironmentID != "" { + return fmt.Sprintf("invalid role assignment: environment %q does not exist", e.EnvironmentID) + } + return "invalid role assignment" +} + +func IsInvalidRoleAssignmentError(err error) bool { + return isErrorTypeInternal[*InvalidRoleAssignmentError](err) +} + +type FederatedCredentialNotFoundError struct{} + +func (e *FederatedCredentialNotFoundError) Error() string { + return "federated credential not found" +} + +func IsErrorFederatedCredentialNotFound(err error) bool { + return isErrorTypeInternal[*FederatedCredentialNotFoundError](err) +} + +type FederatedCredentialInvalidError struct{} + +func (e *FederatedCredentialInvalidError) Error() string { + return "invalid federated credential" +} + +func IsErrorFederatedCredentialInvalid(err error) bool { + return isErrorTypeInternal[*FederatedCredentialInvalidError](err) +} + +// DefaultTransportTypeError is returned when http.DefaultTransport is not the +// expected *http.Transport concrete type and therefore cannot be cloned. +type DefaultTransportTypeError struct{} + +func (e *DefaultTransportTypeError) Error() string { + return "http.DefaultTransport is not *http.Transport" +} + +// ManagerCALockTypeError is returned when a cached manager CA lock value is not +// the expected *sync.Mutex. +type ManagerCALockTypeError struct{} + +func (e *ManagerCALockTypeError) Error() string { + return "manager CA lock value is not *sync.Mutex" +} + +// ECRTokenResultTypeError is returned when a deduplicated ECR token refresh +// yields a value that is not the expected *ecrTokenResult. +type ECRTokenResultTypeError struct{} + +func (e *ECRTokenResultTypeError) Error() string { + return "unexpected ECR token result type" +} + +// OidcProviderCacheTypeError is returned when a cached OIDC provider value is +// not the expected *oidc.Provider. +type OidcProviderCacheTypeError struct{} + +func (e *OidcProviderCacheTypeError) Error() string { + return "unexpected provider type from cache" +} + +type FederatedCredentialInvalidRequestError struct{} + +func (e *FederatedCredentialInvalidRequestError) Error() string { + return "invalid federated token exchange request" +} + +func IsErrorFederatedCredentialInvalidRequest(err error) bool { + return isErrorTypeInternal[*FederatedCredentialInvalidRequestError](err) +} + +type FederatedCredentialInvalidGrantError struct{} + +func (e *FederatedCredentialInvalidGrantError) Error() string { + return "invalid federated token grant" +} + +func IsErrorFederatedCredentialInvalidGrant(err error) bool { + return isErrorTypeInternal[*FederatedCredentialInvalidGrantError](err) +} + +type FederatedCredentialPermissionEscalationError struct{} + +func (e *FederatedCredentialPermissionEscalationError) Error() string { + return "cannot map a federated credential to a role you do not hold" +} + +func IsErrorFederatedCredentialPermissionEscalation(err error) bool { + return isErrorTypeInternal[*FederatedCredentialPermissionEscalationError](err) +} + +type OidcMappingNotFoundError struct{} + +func (e *OidcMappingNotFoundError) Error() string { + return "OIDC role mapping not found" +} + +func IsOidcMappingNotFoundError(err error) bool { + return isErrorTypeInternal[*OidcMappingNotFoundError](err) +} + +// OidcMappingEnvManagedError is returned when an API caller attempts to mutate +// an OIDC role mapping that was declared via OIDC_ROLE_MAPPINGS. Env-managed +// rows can only be changed by editing the env var and restarting. +type OidcMappingEnvManagedError struct{} + +func (e *OidcMappingEnvManagedError) Error() string { + return "OIDC role mapping is managed by OIDC_ROLE_MAPPINGS and cannot be edited at runtime" +} + +func IsOidcMappingEnvManagedError(err error) bool { + return isErrorTypeInternal[*OidcMappingEnvManagedError](err) +} + +type NoGlobalAdminRemainsError struct{} + +func (e *NoGlobalAdminRemainsError) Error() string { + return "At least one user must retain a global Admin role assignment" +} + +func IsNoGlobalAdminRemainsError(err error) bool { + return isErrorTypeInternal[*NoGlobalAdminRemainsError](err) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4893dc7a98..6c99c02731 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,6 +28,9 @@ const ( // Fields with `options:"file"` support Docker secrets via the _FILE suffix. // Available options: file, toLower, trimTrailingSlash type Config struct { + // BuildablesConfig contains feature-specific configuration that can be conditionally compiled + BuildablesConfig + AppUrl string `env:"APP_URL" default:"http://localhost:3552"` DatabaseURL string `env:"DATABASE_URL" default:"file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" options:"file"` AllowDowngrade bool `env:"ALLOW_DOWNGRADE" default:"false"` @@ -37,7 +40,7 @@ type Config struct { TLSCertFile string `env:"TLS_CERT_FILE" default:""` TLSKeyFile string `env:"TLS_KEY_FILE" default:""` Environment AppEnvironment `env:"ENVIRONMENT" default:"production"` - JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` //nolint:gosec // configuration field name is part of stable config API + JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` JWTRefreshExpiry time.Duration `env:"JWT_REFRESH_EXPIRY" default:"168h"` EncryptionKey string `env:"ENCRYPTION_KEY" default:"arcane-dev-key-32-characters!!!" options:"file"` AdminStaticAPIKey string `env:"ADMIN_STATIC_API_KEY" default:"" options:"file"` @@ -47,13 +50,17 @@ type Config struct { OidcClientSecret string `env:"OIDC_CLIENT_SECRET" default:"" options:"file"` OidcIssuerURL string `env:"OIDC_ISSUER_URL" default:""` OidcScopes string `env:"OIDC_SCOPES" default:"openid email profile"` - OidcAdminClaim string `env:"OIDC_ADMIN_CLAIM" default:""` - OidcAdminValue string `env:"OIDC_ADMIN_VALUE" default:""` + OidcGroupsClaim string `env:"OIDC_GROUPS_CLAIM" default:"groups"` OidcSkipTlsVerify bool `env:"OIDC_SKIP_TLS_VERIFY" default:"false"` OidcAutoRedirectToProvider bool `env:"OIDC_AUTO_REDIRECT_TO_PROVIDER" default:"false"` OidcProviderName string `env:"OIDC_PROVIDER_NAME" default:""` OidcProviderLogoUrl string `env:"OIDC_PROVIDER_LOGO_URL" default:""` OidcMobileRedirectUris string `env:"OIDC_MOBILE_REDIRECT_URIS" default:"arcane-mobile://oidc-callback"` + // OidcRoleMappings declaratively defines OIDC group→role mappings as a + // JSON array of role.OidcRoleMappingSpec. Reconciled into source='env' + // rows on every boot; rows are read-only at runtime. Supports *_FILE for + // Docker secrets. Leave empty to manage mappings purely via the UI/API. + OidcRoleMappings string `env:"OIDC_ROLE_MAPPINGS" default:"" options:"file"` PUID string `env:"PUID" default:""` PGID string `env:"PGID" default:""` @@ -101,9 +108,6 @@ type Config struct { // Timezone for cron job scheduling. Uses IANA timezone names (e.g., "America/New_York", "Europe/London"). // "Local" uses the system's local timezone, "UTC" for Coordinated Universal Time. Timezone string `env:"TZ" default:"Local"` - - // BuildablesConfig contains feature-specific configuration that can be conditionally compiled - BuildablesConfig } func Load() *Config { @@ -166,7 +170,7 @@ func loadFromEnv(cfg *Config) { defaultValue := fieldType.Tag.Get("default") // Get the environment value directly first - envValue := trimQuotes(os.Getenv(envTag)) + envValue := pkgutils.TrimQuotes(os.Getenv(envTag)) if envValue == "" { envValue = defaultValue } @@ -214,7 +218,7 @@ func visitConfigFields(v reflect.Value, fn func(reflect.Value, reflect.StructFie } t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) fieldType := t.Field(i) @@ -328,14 +332,14 @@ func setFieldValueInternal(field reflect.Value, fieldType reflect.StructField, v defaultValue := fieldType.Tag.Get("default") if fallback, fallbackErr := time.ParseDuration(defaultValue); fallbackErr == nil { - slog.Warn("Invalid duration for config field, using tagged default", //nolint:gosec // logging invalid config input for diagnostics is intentional here. + slog.Warn("Invalid duration for config field, using tagged default", "reason", reason, "field", envTag, "value", value, "default", defaultValue) field.SetInt(int64(fallback)) } else { - slog.Warn("Invalid duration for config field and invalid tagged default", //nolint:gosec // logging invalid config input for diagnostics is intentional here. + slog.Warn("Invalid duration for config field and invalid tagged default", "reason", reason, "field", envTag, "value", value, @@ -367,15 +371,6 @@ func setFieldValueInternal(field reflect.Value, fieldType reflect.StructField, v } } -func trimQuotes(s string) string { - if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { - return s[1 : len(s)-1] - } - } - return s -} - func (a AppEnvironment) IsProdEnvironment() bool { return a == AppEnvironmentProduction } @@ -481,7 +476,7 @@ func (c *Config) MaskSensitive() map[string]any { v := reflect.ValueOf(c).Elem() t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) fieldType := t.Field(i) diff --git a/backend/internal/configschema/schema.go b/backend/internal/configschema/schema.go index bdd036d969..6481b361b7 100644 --- a/backend/internal/configschema/schema.go +++ b/backend/internal/configschema/schema.go @@ -3,6 +3,7 @@ package configschema import ( "bytes" "encoding/json" + "errors" "fmt" "go/ast" "go/format" @@ -91,41 +92,12 @@ type overrideDocRule struct { } var overrideDocRules = map[string]overrideDocRule{ - "authOidcConfig": { - deprecated: true, - note: "Deprecated legacy JSON OIDC configuration. Prefer the discrete OIDC_* environment variables.", - }, "autoUpdateInterval": { requires: "AUTO_UPDATE=true to have effect at runtime.", }, "scheduledPruneInterval": { requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", }, - "scheduledPruneContainers": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_CONTAINER_MODE.", - }, - "scheduledPruneImages": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_IMAGE_MODE.", - }, - "scheduledPruneVolumes": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_VOLUME_MODE.", - }, - "scheduledPruneNetworks": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_NETWORK_MODE.", - }, - "scheduledPruneBuildCache": { - deprecated: true, - requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", - note: "Legacy boolean prune flag retained for migration compatibility. Prefer PRUNE_BUILD_CACHE_MODE.", - }, "pruneContainerMode": { requires: "SCHEDULED_PRUNE_ENABLED=true to have effect at runtime.", }, @@ -153,10 +125,6 @@ var overrideDocRules = map[string]overrideDocRule{ "pruneBuildCacheUntil": { requires: "SCHEDULED_PRUNE_ENABLED=true and PRUNE_BUILD_CACHE_MODE=olderThan to have effect at runtime.", }, - "dockerPruneMode": { - deprecated: true, - note: "Legacy prune mode retained for migration compatibility. Prefer the granular scheduled prune mode settings.", - }, "vulnerabilityScanInterval": { requires: "VULNERABILITY_SCAN_ENABLED=true to have effect at runtime.", }, @@ -476,7 +444,7 @@ func resolveSourceRootInternal(sourceRoot string) (string, error) { return "", fmt.Errorf("resolve source root from %q: expected backend/internal/config/config.go", sourceRoot) } - return "", fmt.Errorf("resolve source root: run from the repository root/backend directory or pass --source-root") + return "", errors.New("resolve source root: run from the repository root/backend directory or pass --source-root") } func resolveSourceRootCandidateInternal(candidate string) (string, error) { diff --git a/backend/internal/configschema/schema_test.go b/backend/internal/configschema/schema_test.go index 617b7df3fe..89d07d7c80 100644 --- a/backend/internal/configschema/schema_test.go +++ b/backend/internal/configschema/schema_test.go @@ -77,13 +77,6 @@ func TestGenerate_SettingEnvOverridesMatchModelMetadata(t *testing.T) { assert.Equal(t, "arcane-mobile://oidc-callback", oidcMobileRedirectURIs.DefaultValue) assert.Equal(t, "authentication", oidcMobileRedirectURIs.Category) - legacyOIDC, ok := entries["authOidcConfig"] - require.True(t, ok) - assert.True(t, legacyOIDC.Deprecated) - assert.NotEmpty(t, legacyOIDC.Note) - assert.Contains(t, legacyOIDC.Requires, "AGENT_MODE=true") - assert.Empty(t, legacyOIDC.DefaultValue) - trivyImage, ok := entries["trivyImage"] require.True(t, ok) assert.Contains(t, trivyImage.Requires, "UI_CONFIGURATION_DISABLED=true") @@ -237,16 +230,16 @@ var expectedEnvConfigVars = []string{ "LOG_JSON", "LOG_LEVEL", "MANAGER_API_URL", - "OIDC_ADMIN_CLAIM", - "OIDC_ADMIN_VALUE", "OIDC_AUTO_REDIRECT_TO_PROVIDER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_ENABLED", + "OIDC_GROUPS_CLAIM", "OIDC_ISSUER_URL", "OIDC_MOBILE_REDIRECT_URIS", "OIDC_PROVIDER_LOGO_URL", "OIDC_PROVIDER_NAME", + "OIDC_ROLE_MAPPINGS", "OIDC_SCOPES", "OIDC_SKIP_TLS_VERIFY", "PGID", @@ -270,9 +263,10 @@ var expectedEnvConfigVars = []string{ var expectedSettingOverrideKeys = []string{ "accentColor", + "activityHistoryMaxEntries", + "activityHistoryRetentionDays", "applicationTheme", "authLocalEnabled", - "authOidcConfig", "authPasswordPolicy", "authSessionTimeout", "autoHealEnabled", @@ -309,17 +303,18 @@ var expectedSettingOverrideKeys = []string{ "gitopsSyncInterval", "httpClientTimeout", "keyboardShortcutsEnabled", + "lifecycleEnabled", + "lifecycleMaxTimeoutSec", "maxImageUploadSize", "mobileNavigationMode", "mobileNavigationShowLabels", - "oidcAdminClaim", - "oidcAdminValue", "oidcAuthorizationEndpoint", "oidcAutoRedirectToProvider", "oidcClientId", "oidcClientSecret", "oidcDeviceAuthorizationEndpoint", "oidcEnabled", + "oidcGroupsClaim", "oidcIssuerUrl", "oidcJwksEndpoint", "oidcMergeAccounts", @@ -345,13 +340,8 @@ var expectedSettingOverrideKeys = []string{ "pruneNetworkUntil", "pruneVolumeMode", "registryTimeout", - "scheduledPruneBuildCache", - "scheduledPruneContainers", "scheduledPruneEnabled", - "scheduledPruneImages", "scheduledPruneInterval", - "scheduledPruneNetworks", - "scheduledPruneVolumes", "sidebarHoverExpansion", "swarmStackSourcesDirectory", "templatesDirectory", diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index f44ca17e35..9b133f3a96 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -19,6 +19,8 @@ import ( postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source" + + // Registers the "github" source driver for golang-migrate (remote migration sources). _ "github.com/golang-migrate/migrate/v4/source/github" "github.com/golang-migrate/migrate/v4/source/iofs" "gorm.io/driver/postgres" @@ -532,5 +534,5 @@ func ensureSQLiteDirectory(connString string) error { if dir == "" || dir == "." { return nil } - return os.MkdirAll(dir, 0o755) //nolint:gosec // directory path is intentionally derived from configured SQLite DSN + return os.MkdirAll(dir, 0o755) } diff --git a/backend/internal/di/providers.go b/backend/internal/di/providers.go new file mode 100644 index 0000000000..159d8d0a7a --- /dev/null +++ b/backend/internal/di/providers.go @@ -0,0 +1,183 @@ +// Package di owns the backend's dependency-injection graph. The service +// construction order is generated by google/wire (see wire.go / wire_gen.go); +// this file holds the aggregate Services container and the small set of +// provider wrappers wire cannot synthesize on its own. +package di + +import ( + "context" + "embed" + "log/slog" + "net/http" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/middleware" + "github.com/getarcaneapp/arcane/backend/internal/services" + pkg_scheduler "github.com/getarcaneapp/arcane/backend/pkg/scheduler" + "github.com/getarcaneapp/arcane/backend/resources" +) + +// Services is the single aggregate of every constructed service. wire populates +// every field via wire.Struct(new(Services), "*"); it is the only service +// container in the backend (handlers, jobs, and the router all read from it). +type Services struct { + AppImages *services.ApplicationImagesService + User *services.UserService + Project *services.ProjectService + Environment *services.EnvironmentService + Settings *services.SettingsService + KV *services.KVService + JobSchedule *services.JobService + SettingsSearch *services.SettingsSearchService + CustomizeSearch *services.CustomizeSearchService + Container *services.ContainerService + Image *services.ImageService + Build *services.BuildService + BuildWorkspace *services.BuildWorkspaceService + Lifecycle *services.LifecycleService + Volume *services.VolumeService + Network *services.NetworkService + Port *services.PortService + Swarm *services.SwarmService + ImageUpdate *services.ImageUpdateService + Session *services.SessionService + Auth *services.AuthService + Oidc *services.OidcService + Docker *services.DockerClientService + Template *services.TemplateService + ContainerRegistry *services.ContainerRegistryService + System *services.SystemService + SystemUpgrade *services.SystemUpgradeService + Diagnostics *services.DiagnosticsService + Updater *services.UpdaterService + Event *services.EventService + Activity *services.ActivityService + Version *services.VersionService + Notification *services.NotificationService + ApiKey *services.ApiKeyService + Federated *services.FederatedCredentialService + GitRepository *services.GitRepositoryService + GitOpsSync *services.GitOpsSyncService + Webhook *services.WebhookService + Vulnerability *services.VulnerabilityService + Dashboard *services.DashboardService + Role *services.RoleService + // AuthMiddleware is the shared Echo auth middleware. It's wired here so the + // router consumes it from the container instead of building the chain inline. + AuthMiddleware *middleware.AuthMiddleware +} + +// DockerClient returns the Docker client service. Bootstrap needs the docker +// service on its own (for Close and startup state) before the rest of the +// container is consumed; this avoids a second return value from the injector. +func (s *Services) DockerClient() *services.DockerClientService { + return s.Docker +} + +// provideResourcesFSInternal exposes the embedded resource filesystem to the graph so +// wire can call services.NewApplicationImagesService directly. +func provideResourcesFSInternal() embed.FS { + return resources.FS +} + +// The following wrappers exist only because wire cannot synthesize these +// constructors by type: they read scalar fields/package vars off the config, +// take parameters of unexported interface types, or apply a post-construction +// builder. Keeping them here (in the untagged file) lets the generated +// wire_gen.go call them in a normal build. + +// provideVersionServiceInternal supplies the bool/string scalars wire can't inject by type. +func provideVersionServiceInternal(httpClient *http.Client, cfg *config.Config, registry *services.ContainerRegistryService, docker *services.DockerClientService, imageUpdate *services.ImageUpdateService) *services.VersionService { + return services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, registry, docker, imageUpdate) +} + +// provideGitRepositoryServiceInternal supplies cfg.GitWorkDir (bare string). +func provideGitRepositoryServiceInternal(db *database.DB, cfg *config.Config, event *services.EventService, settings *services.SettingsService) *services.GitRepositoryService { + return services.NewGitRepositoryService(db, cfg.GitWorkDir, event, settings) +} + +// provideVolumeServiceInternal supplies cfg.BackupVolumeName (bare string). +func provideVolumeServiceInternal(db *database.DB, docker *services.DockerClientService, event *services.EventService, settings *services.SettingsService, container *services.ContainerService, image *services.ImageService, cfg *config.Config) *services.VolumeService { + return services.NewVolumeService(db, docker, event, settings, container, image, cfg.BackupVolumeName) +} + +// provideAuthServiceInternal supplies cfg.JWTSecret (bare string). +func provideAuthServiceInternal(user *services.UserService, settings *services.SettingsService, event *services.EventService, session *services.SessionService, role *services.RoleService, cfg *config.Config) *services.AuthService { + return services.NewAuthService(user, settings, event, session, role, cfg.JWTSecret, cfg) +} + +// provideContainerRegistryServiceInternal builds the registryDaemonGetter closure from +// the docker service. registryDaemonGetter is unexported, so wire can't name it. +func provideContainerRegistryServiceInternal(db *database.DB, docker *services.DockerClientService, kv *services.KVService) *services.ContainerRegistryService { + return services.NewContainerRegistryService(db, func(ctx context.Context) (services.RegistryDaemonClient, error) { + return docker.GetClient(ctx) + }, kv) +} + +// provideUpdaterServiceInternal passes *SystemUpgradeService for the unexported +// selfUpgradeService parameter so wire never sees the unexported type. +func provideUpdaterServiceInternal(db *database.DB, settings *services.SettingsService, docker *services.DockerClientService, project *services.ProjectService, imageUpdate *services.ImageUpdateService, registry *services.ContainerRegistryService, event *services.EventService, image *services.ImageService, notification *services.NotificationService, systemUpgrade *services.SystemUpgradeService, activity *services.ActivityService) *services.UpdaterService { + return services.NewUpdaterService(db, settings, docker, project, imageUpdate, registry, event, image, notification, systemUpgrade, activity) +} + +// provideUserServiceInternal applies the RoleService builder. The builder stays on the +// type for tests; wire calls this wrapper. +func provideUserServiceInternal(db *database.DB, role *services.RoleService) *services.UserService { + return services.NewUserService(db).WithRoleService(role) +} + +// provideApiKeyServiceInternal applies the RoleService builder. +func provideApiKeyServiceInternal(db *database.DB, user *services.UserService, role *services.RoleService) *services.ApiKeyService { + return services.NewApiKeyService(db, user).WithRoleService(role) +} + +// provideFederatedCredentialServiceInternal applies the RoleService builder. +func provideFederatedCredentialServiceInternal(db *database.DB, auth *services.AuthService, user *services.UserService, settings *services.SettingsService, event *services.EventService, httpClient *http.Client, role *services.RoleService) *services.FederatedCredentialService { + return services.NewFederatedCredentialService(db, auth, user, settings, event, httpClient).WithRoleService(role) +} + +// provideAuthMiddlewareInternal builds the shared Echo auth middleware. The builder +// methods take interfaces (ApiKeyValidator / EnvironmentAccessTokenResolver / +// PermissionResolver) that the concrete services already satisfy. +func provideAuthMiddlewareInternal(auth *services.AuthService, apiKey *services.ApiKeyService, env *services.EnvironmentService, role *services.RoleService, cfg *config.Config) *middleware.AuthMiddleware { + return middleware.NewAuthMiddleware(auth, cfg). + WithApiKeyValidator(apiKey). + WithEnvironmentAccessTokenResolver(env). + WithPermissionResolver(role) +} + +// Jobs aggregates every scheduler job. wire populates it via wire.Struct; the +// bootstrap then registers the constructed jobs with the scheduler and wires the +// settings-change callbacks — those are post-construction concerns wire does not handle. +type Jobs struct { + AutoUpdate *pkg_scheduler.AutoUpdateJob + ImagePolling *pkg_scheduler.ImagePollingJob + EnvironmentHealth *pkg_scheduler.EnvironmentHealthJob + DockerClientRefresh *pkg_scheduler.DockerClientRefreshJob + Analytics *pkg_scheduler.AnalyticsJob + EventCleanup *pkg_scheduler.EventCleanupJob + ExpiredSessionsCleanup *pkg_scheduler.ExpiredSessionsCleanupJob + ScheduledPrune *pkg_scheduler.ScheduledPruneJob + FilesystemWatcher *pkg_scheduler.FilesystemWatcherJob + GitOpsSync *pkg_scheduler.GitOpsSyncJob + VulnerabilityScan *pkg_scheduler.VulnerabilityScanJob + AutoHeal *pkg_scheduler.AutoHealJob +} + +// provideAnalyticsJobInternal preserves the existing nil http.Client (the analytics job +// builds its own default client when none is supplied). +func provideAnalyticsJobInternal(settings *services.SettingsService, kv *services.KVService, cfg *config.Config) *pkg_scheduler.AnalyticsJob { + return pkg_scheduler.NewAnalyticsJob(settings, kv, nil, cfg) +} + +// provideFilesystemWatcherJobInternal supplies ctx + cfg.ProjectScanMaxDepth and keeps the +// watcher-setup failure non-fatal (logged; a nil job is tolerated downstream), matching +// the previous bootstrap behavior. +func provideFilesystemWatcherJobInternal(ctx context.Context, project *services.ProjectService, template *services.TemplateService, settings *services.SettingsService, cfg *config.Config) *pkg_scheduler.FilesystemWatcherJob { + job, err := pkg_scheduler.RegisterFilesystemWatcherJob(ctx, project, template, settings, cfg.ProjectScanMaxDepth) + if err != nil { + slog.ErrorContext(ctx, "Failed to register filesystem watcher job", "error", err) + } + return job +} diff --git a/backend/internal/di/wire.go b/backend/internal/di/wire.go new file mode 100644 index 0000000000..71d25a46ca --- /dev/null +++ b/backend/internal/di/wire.go @@ -0,0 +1,116 @@ +//go:build wireinject + +// This file is consumed only by wire's code generator (build tag wireinject) +// and is excluded from normal builds. Run `go generate ./internal/di/` after +// changing the provider set to regenerate wire_gen.go. + +package di + +import ( + "context" + "net/http" + + "github.com/google/wire" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/services" + pkg_scheduler "github.com/getarcaneapp/arcane/backend/pkg/scheduler" +) + +// ServiceSet is the single, central provider set for the whole backend. Every +// service constructor (or the wrapper it requires) is listed here exactly once; +// wire derives the construction order from the dependency graph, so ordering is +// no longer maintained by hand. wire.Struct assembles the aggregate Services. +var ServiceSet = wire.NewSet( + // Infra providers that are not themselves services. + provideResourcesFSInternal, + + // Services wire constructs directly via their real constructors. + services.NewEventService, + services.NewActivityService, + services.NewSettingsService, // returns (*SettingsService, error); wire threads the error. + services.NewKVService, + services.NewJobService, + services.NewSettingsSearchService, + services.NewCustomizeSearchService, + services.NewApplicationImagesService, + services.NewDockerClientService, + services.NewRoleService, + services.NewSessionService, + services.NewEnvironmentService, + services.NewNotificationService, + services.NewVulnerabilityService, + services.NewImageUpdateService, + services.NewImageService, + services.NewBuildService, + services.NewBuildWorkspaceService, + services.NewLifecycleService, + services.NewProjectService, + services.NewContainerService, + services.NewDashboardService, + services.NewNetworkService, + services.NewPortService, + services.NewSwarmService, + services.NewTemplateService, + services.NewOidcService, + services.NewSystemService, + services.NewSystemUpgradeService, + services.NewDiagnosticsService, + services.NewGitOpsSyncService, + services.NewWebhookService, + + // Services that require a wrapper (scalar config field, unexported parameter, + // or post-construction RoleService builder). See providers.go. + provideVersionServiceInternal, + provideGitRepositoryServiceInternal, + provideVolumeServiceInternal, + provideAuthServiceInternal, + provideContainerRegistryServiceInternal, + provideUpdaterServiceInternal, + provideUserServiceInternal, + provideApiKeyServiceInternal, + provideFederatedCredentialServiceInternal, + + // Shared Echo auth middleware (built from the auth-related services + config). + provideAuthMiddlewareInternal, + + // Assemble the aggregate container from everything above. + wire.Struct(new(Services), "*"), +) + +// InitializeServices builds the full service graph. ctx, db, cfg, and httpClient +// are graph inputs supplied by the caller; every other value is constructed by +// ServiceSet. The generated implementation lives in wire_gen.go. +func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (*Services, error) { + panic(wire.Build(ServiceSet)) +} + +// JobSet builds every scheduler job from an already-constructed *Services (whose +// fields are exposed to the graph via wire.FieldsOf) plus the app context and config. +var JobSet = wire.NewSet( + wire.FieldsOf(new(*Services), + "Updater", "Settings", "ImageUpdate", "Environment", "Docker", "KV", + "Event", "Activity", "Session", "System", "Notification", "Project", + "Template", "GitOpsSync", "Vulnerability", + ), + pkg_scheduler.NewAutoUpdateJob, + pkg_scheduler.NewImagePollingJob, + pkg_scheduler.NewEnvironmentHealthJob, + pkg_scheduler.NewDockerClientRefreshJob, + provideAnalyticsJobInternal, + pkg_scheduler.NewEventCleanupJob, + pkg_scheduler.NewExpiredSessionsCleanupJob, + pkg_scheduler.NewScheduledPruneJob, + provideFilesystemWatcherJobInternal, + pkg_scheduler.NewGitOpsSyncJob, + pkg_scheduler.NewVulnerabilityScanJob, + pkg_scheduler.NewAutoHealJob, + wire.Struct(new(Jobs), "*"), +) + +// InitializeJobs constructs all scheduler jobs from the built services. The caller +// registers them with the scheduler and wires the settings-change callbacks. +func InitializeJobs(ctx context.Context, cfg *config.Config, svcs *Services) *Jobs { + panic(wire.Build(JobSet)) +} diff --git a/backend/internal/di/wire_gen.go b/backend/internal/di/wire_gen.go new file mode 100644 index 0000000000..fc0d8bb70f --- /dev/null +++ b/backend/internal/di/wire_gen.go @@ -0,0 +1,193 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package di + +import ( + "context" + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/scheduler" + "github.com/google/wire" + "net/http" +) + +// Injectors from wire.go: + +// InitializeServices builds the full service graph. ctx, db, cfg, and httpClient +// are graph inputs supplied by the caller; every other value is constructed by +// ServiceSet. The generated implementation lives in wire_gen.go. +func InitializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (*Services, error) { + fs := provideResourcesFSInternal() + settingsService, err := services.NewSettingsService(ctx, db) + if err != nil { + return nil, err + } + applicationImagesService := services.NewApplicationImagesService(fs, settingsService) + roleService := services.NewRoleService(db) + userService := provideUserServiceInternal(db, roleService) + eventService := services.NewEventService(db, cfg, httpClient) + dockerClientService := services.NewDockerClientService(ctx, db, cfg, settingsService) + kvService := services.NewKVService(db) + containerRegistryService := provideContainerRegistryServiceInternal(db, dockerClientService, kvService) + apiKeyService := provideApiKeyServiceInternal(db, userService, roleService) + environmentService := services.NewEnvironmentService(db, httpClient, dockerClientService, eventService, settingsService, apiKeyService) + notificationService := services.NewNotificationService(db, cfg, environmentService) + activityService := services.NewActivityService(db) + imageUpdateService := services.NewImageUpdateService(db, settingsService, containerRegistryService, dockerClientService, eventService, notificationService, activityService) + vulnerabilityService := services.NewVulnerabilityService(db, dockerClientService, eventService, settingsService, notificationService, activityService, containerRegistryService) + imageService := services.NewImageService(db, dockerClientService, containerRegistryService, imageUpdateService, vulnerabilityService, eventService) + gitRepositoryService := provideGitRepositoryServiceInternal(db, cfg, eventService, settingsService) + buildService := services.NewBuildService(db, settingsService, dockerClientService, containerRegistryService, gitRepositoryService, eventService) + lifecycleService := services.NewLifecycleService(db, settingsService, eventService, dockerClientService) + projectService := services.NewProjectService(db, settingsService, eventService, imageService, dockerClientService, buildService, lifecycleService, cfg) + jobService := services.NewJobService(db, settingsService, cfg) + settingsSearchService := services.NewSettingsSearchService() + customizeSearchService := services.NewCustomizeSearchService() + containerService := services.NewContainerService(ctx, db, eventService, dockerClientService, imageService, settingsService, projectService) + buildWorkspaceService := services.NewBuildWorkspaceService(settingsService) + volumeService := provideVolumeServiceInternal(db, dockerClientService, eventService, settingsService, containerService, imageService, cfg) + networkService := services.NewNetworkService(db, dockerClientService, eventService) + portService := services.NewPortService(dockerClientService) + swarmService := services.NewSwarmService(dockerClientService, settingsService, kvService, containerRegistryService, environmentService) + sessionService := services.NewSessionService(db) + authService := provideAuthServiceInternal(userService, settingsService, eventService, sessionService, roleService, cfg) + oidcService := services.NewOidcService(authService, settingsService, cfg, httpClient) + templateService := services.NewTemplateService(ctx, db, httpClient, settingsService) + systemService := services.NewSystemService(db, dockerClientService, containerService, imageService, volumeService, networkService, settingsService, activityService) + versionService := provideVersionServiceInternal(httpClient, cfg, containerRegistryService, dockerClientService, imageUpdateService) + systemUpgradeService := services.NewSystemUpgradeService(dockerClientService, versionService, eventService, settingsService) + diagnosticsService := services.NewDiagnosticsService() + updaterService := provideUpdaterServiceInternal(db, settingsService, dockerClientService, projectService, imageUpdateService, containerRegistryService, eventService, imageService, notificationService, systemUpgradeService, activityService) + federatedCredentialService := provideFederatedCredentialServiceInternal(db, authService, userService, settingsService, eventService, httpClient, roleService) + gitOpsSyncService := services.NewGitOpsSyncService(db, gitRepositoryService, projectService, swarmService, eventService, settingsService) + webhookService := services.NewWebhookService(db, containerService, updaterService, projectService, gitOpsSyncService, eventService) + dashboardService := services.NewDashboardService(db, dockerClientService, containerService, projectService, imageService, settingsService, vulnerabilityService, environmentService, versionService) + authMiddleware := provideAuthMiddlewareInternal(authService, apiKeyService, environmentService, roleService, cfg) + diServices := &Services{ + AppImages: applicationImagesService, + User: userService, + Project: projectService, + Environment: environmentService, + Settings: settingsService, + KV: kvService, + JobSchedule: jobService, + SettingsSearch: settingsSearchService, + CustomizeSearch: customizeSearchService, + Container: containerService, + Image: imageService, + Build: buildService, + BuildWorkspace: buildWorkspaceService, + Lifecycle: lifecycleService, + Volume: volumeService, + Network: networkService, + Port: portService, + Swarm: swarmService, + ImageUpdate: imageUpdateService, + Session: sessionService, + Auth: authService, + Oidc: oidcService, + Docker: dockerClientService, + Template: templateService, + ContainerRegistry: containerRegistryService, + System: systemService, + SystemUpgrade: systemUpgradeService, + Diagnostics: diagnosticsService, + Updater: updaterService, + Event: eventService, + Activity: activityService, + Version: versionService, + Notification: notificationService, + ApiKey: apiKeyService, + Federated: federatedCredentialService, + GitRepository: gitRepositoryService, + GitOpsSync: gitOpsSyncService, + Webhook: webhookService, + Vulnerability: vulnerabilityService, + Dashboard: dashboardService, + Role: roleService, + AuthMiddleware: authMiddleware, + } + return diServices, nil +} + +// InitializeJobs constructs all scheduler jobs from the built services. The caller +// registers them with the scheduler and wires the settings-change callbacks. +func InitializeJobs(ctx context.Context, cfg *config.Config, svcs *Services) *Jobs { + updaterService := svcs.Updater + settingsService := svcs.Settings + autoUpdateJob := scheduler.NewAutoUpdateJob(updaterService, settingsService) + imageUpdateService := svcs.ImageUpdate + environmentService := svcs.Environment + imagePollingJob := scheduler.NewImagePollingJob(imageUpdateService, settingsService, environmentService) + environmentHealthJob := scheduler.NewEnvironmentHealthJob(environmentService, settingsService) + dockerClientService := svcs.Docker + dockerClientRefreshJob := scheduler.NewDockerClientRefreshJob(dockerClientService, settingsService) + kvService := svcs.KV + analyticsJob := provideAnalyticsJobInternal(settingsService, kvService, cfg) + eventService := svcs.Event + activityService := svcs.Activity + eventCleanupJob := scheduler.NewEventCleanupJob(eventService, activityService, settingsService) + sessionService := svcs.Session + expiredSessionsCleanupJob := scheduler.NewExpiredSessionsCleanupJob(sessionService, settingsService) + systemService := svcs.System + notificationService := svcs.Notification + scheduledPruneJob := scheduler.NewScheduledPruneJob(systemService, settingsService, notificationService) + projectService := svcs.Project + templateService := svcs.Template + filesystemWatcherJob := provideFilesystemWatcherJobInternal(ctx, projectService, templateService, settingsService, cfg) + gitOpsSyncService := svcs.GitOpsSync + gitOpsSyncJob := scheduler.NewGitOpsSyncJob(gitOpsSyncService, settingsService) + vulnerabilityService := svcs.Vulnerability + vulnerabilityScanJob := scheduler.NewVulnerabilityScanJob(vulnerabilityService, settingsService) + autoHealJob := scheduler.NewAutoHealJob(dockerClientService, settingsService, eventService, notificationService) + jobs := &Jobs{ + AutoUpdate: autoUpdateJob, + ImagePolling: imagePollingJob, + EnvironmentHealth: environmentHealthJob, + DockerClientRefresh: dockerClientRefreshJob, + Analytics: analyticsJob, + EventCleanup: eventCleanupJob, + ExpiredSessionsCleanup: expiredSessionsCleanupJob, + ScheduledPrune: scheduledPruneJob, + FilesystemWatcher: filesystemWatcherJob, + GitOpsSync: gitOpsSyncJob, + VulnerabilityScan: vulnerabilityScanJob, + AutoHeal: autoHealJob, + } + return jobs +} + +// wire.go: + +// ServiceSet is the single, central provider set for the whole backend. Every +// service constructor (or the wrapper it requires) is listed here exactly once; +// wire derives the construction order from the dependency graph, so ordering is +// no longer maintained by hand. wire.Struct assembles the aggregate Services. +var ServiceSet = wire.NewSet( + + provideResourcesFSInternal, services.NewEventService, services.NewActivityService, services.NewSettingsService, services.NewKVService, services.NewJobService, services.NewSettingsSearchService, services.NewCustomizeSearchService, services.NewApplicationImagesService, services.NewDockerClientService, services.NewRoleService, services.NewSessionService, services.NewEnvironmentService, services.NewNotificationService, services.NewVulnerabilityService, services.NewImageUpdateService, services.NewImageService, services.NewBuildService, services.NewBuildWorkspaceService, services.NewLifecycleService, services.NewProjectService, services.NewContainerService, services.NewDashboardService, services.NewNetworkService, services.NewPortService, services.NewSwarmService, services.NewTemplateService, services.NewOidcService, services.NewSystemService, services.NewSystemUpgradeService, services.NewDiagnosticsService, services.NewGitOpsSyncService, services.NewWebhookService, provideVersionServiceInternal, + provideGitRepositoryServiceInternal, + provideVolumeServiceInternal, + provideAuthServiceInternal, + provideContainerRegistryServiceInternal, + provideUpdaterServiceInternal, + provideUserServiceInternal, + provideApiKeyServiceInternal, + provideFederatedCredentialServiceInternal, + + provideAuthMiddlewareInternal, wire.Struct(new(Services), "*"), +) + +// JobSet builds every scheduler job from an already-constructed *Services (whose +// fields are exposed to the graph via wire.FieldsOf) plus the app context and config. +var JobSet = wire.NewSet(wire.FieldsOf(new(*Services), + "Updater", "Settings", "ImageUpdate", "Environment", "Docker", "KV", + "Event", "Activity", "Session", "System", "Notification", "Project", + "Template", "GitOpsSync", "Vulnerability", +), scheduler.NewAutoUpdateJob, scheduler.NewImagePollingJob, scheduler.NewEnvironmentHealthJob, scheduler.NewDockerClientRefreshJob, provideAnalyticsJobInternal, scheduler.NewEventCleanupJob, scheduler.NewExpiredSessionsCleanupJob, scheduler.NewScheduledPruneJob, provideFilesystemWatcherJobInternal, scheduler.NewGitOpsSyncJob, scheduler.NewVulnerabilityScanJob, scheduler.NewAutoHealJob, wire.Struct(new(Jobs), "*"), +) diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index fba051dbd7..c1bdc60924 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -11,28 +11,47 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + "github.com/getarcaneapp/arcane/backend/pkg/authz" pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cookie" "github.com/labstack/echo/v4" ) +// Echo context keys, kept aligned with api/middleware/auth.go constants so +// shared handlers can read them regardless of which auth layer attached them. +const ( + echoCtxKeyUserID = "userID" + echoCtxKeyCurrentUser = "currentUser" + echoCtxKeyUserPermissions = "userPermissions" + echoCtxKeyAuthMethod = "authMethod" + echoCtxKeySessionID = "currentSessionID" +) + type AuthOptions struct { AdminRequired bool SuccessOptional bool } type ApiKeyValidator interface { - ValidateApiKey(ctx context.Context, rawKey string) (*models.User, error) + ValidateApiKeyWithID(ctx context.Context, rawKey string) (*models.User, string, error) } type EnvironmentAccessTokenResolver interface { ResolveEnvironmentByAccessToken(ctx context.Context, token string) (*models.Environment, error) } +// PermissionResolver resolves a caller's effective permission set. Implemented +// by services.RoleService; kept as an interface so tests can stub it. +type PermissionResolver interface { + ResolvePermissions(ctx context.Context, user *models.User) (*authz.PermissionSet, error) + ResolveApiKeyPermissions(ctx context.Context, apiKeyID string) (*authz.PermissionSet, error) +} + type AuthMiddleware struct { authService *services.AuthService apiKeyValidator ApiKeyValidator envTokenResolver EnvironmentAccessTokenResolver + roleResolver PermissionResolver cfg *config.Config options AuthOptions } @@ -57,6 +76,12 @@ func (m *AuthMiddleware) WithEnvironmentAccessTokenResolver(resolver Environment return &clone } +func (m *AuthMiddleware) WithPermissionResolver(resolver PermissionResolver) *AuthMiddleware { + clone := *m + clone.roleResolver = resolver + return &clone +} + func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware { clone := *m clone.options.AdminRequired = false @@ -69,6 +94,30 @@ func (m *AuthMiddleware) WithAdminRequired() *AuthMiddleware { return &clone } +// RequirePermission returns an Echo middleware that rejects callers lacking +// `perm` for the environment ID in the request path (or globally for org-level +// permissions). Use on streaming/WS routes that aren't served by Huma. Expects +// the caller's PermissionSet to already be on the Echo context via the +// AuthMiddleware (i.e., chain it AFTER auth). +func RequirePermission(perm string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ps, _ := c.Get(echoCtxKeyUserPermissions).(*authz.PermissionSet) + envID := "" + if authz.IsEnvScoped(perm) { + envID = authz.EnvIDFromPath(c.Request().URL.Path) + } + if !ps.Allows(perm, envID) { + return c.JSON(http.StatusForbidden, models.APIError{ + Code: "FORBIDDEN", + Message: "permission denied: " + perm, + }) + } + return next(c) + } + } +} + func (m *AuthMiddleware) Add() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -130,19 +179,19 @@ func (m *AuthMiddleware) managerAuth(ctx context.Context, c echo.Context, next e // First, check for API key in X-API-Key header if apiKey := req.Header.Get(pkgutils.HeaderApiKey); apiKey != "" { if m.apiKeyValidator != nil { - user, err := m.apiKeyValidator.ValidateApiKey(ctx, apiKey) + user, keyID, err := m.apiKeyValidator.ValidateApiKeyWithID(ctx, apiKey) if err == nil && user != nil { - isAdmin := pkgutils.UserHasRole(user.Roles, "admin") - if m.options.AdminRequired && !isAdmin { + ps := m.resolveApiKeyPermissionsOrDeny(ctx, keyID) + if m.options.AdminRequired && !ps.IsGlobalAdmin() { return c.JSON(http.StatusForbidden, models.APIError{ Code: "FORBIDDEN", Message: "You don't have permission to access this resource", }) } - c.Set("userID", user.ID) - c.Set("currentUser", user) - c.Set("userIsAdmin", isAdmin) - c.Set("authMethod", "api_key") + c.Set(echoCtxKeyUserID, user.ID) + c.Set(echoCtxKeyCurrentUser, user) + c.Set(echoCtxKeyUserPermissions, ps) + c.Set(echoCtxKeyAuthMethod, "api_key") return next(c) } } @@ -186,21 +235,51 @@ func (m *AuthMiddleware) managerAuth(ctx context.Context, c echo.Context, next e }) } - isAdmin := pkgutils.UserHasRole(user.Roles, "admin") - if m.options.AdminRequired && !isAdmin { + ps := m.resolvePermissionsOrDeny(ctx, user) + if m.options.AdminRequired && !ps.IsGlobalAdmin() { return c.JSON(http.StatusForbidden, models.APIError{ Code: "FORBIDDEN", Message: "You don't have permission to access this resource", }) } - c.Set("userID", user.ID) - c.Set("currentUser", user) - c.Set("currentSessionID", sessionID) - c.Set("userIsAdmin", isAdmin) + c.Set(echoCtxKeyUserID, user.ID) + c.Set(echoCtxKeyCurrentUser, user) + c.Set(echoCtxKeySessionID, sessionID) + c.Set(echoCtxKeyUserPermissions, ps) return next(c) } +// resolvePermissionsOrDeny returns the user's permission set, or an empty +// (deny-all) set if the resolver is unavailable or fails. Resolver failures +// are logged. +func (m *AuthMiddleware) resolvePermissionsOrDeny(ctx context.Context, user *models.User) *authz.PermissionSet { + if m.roleResolver == nil || user == nil { + return authz.NewPermissionSet() + } + ps, err := m.roleResolver.ResolvePermissions(ctx, user) + if err != nil || ps == nil { + slog.WarnContext(ctx, "failed to resolve user permissions for Echo auth", "error", err) + return authz.NewPermissionSet() + } + return ps +} + +// resolveApiKeyPermissionsOrDeny returns the API key's per-key PermissionSet, +// or an empty (deny-all) set on failure. Falling back to the owner's role +// permissions would defeat per-key scoping, so failures are explicitly denied. +func (m *AuthMiddleware) resolveApiKeyPermissionsOrDeny(ctx context.Context, apiKeyID string) *authz.PermissionSet { + if m.roleResolver == nil || apiKeyID == "" { + return authz.NewPermissionSet() + } + ps, err := m.roleResolver.ResolveApiKeyPermissions(ctx, apiKeyID) + if err != nil || ps == nil { + slog.WarnContext(ctx, "failed to resolve api key permissions for Echo auth", "error", err) + return authz.NewPermissionSet() + } + return ps +} + func (m *AuthMiddleware) resolveEnvironmentAccessToken(ctx context.Context, token string) (*models.Environment, bool) { if m.envTokenResolver == nil { return nil, false @@ -223,24 +302,22 @@ func agentSudoInternal(c echo.Context) { BaseModel: models.BaseModel{ID: "agent"}, Email: new("agent@getarcane.app"), Username: "agent", - Roles: []string{"admin"}, } - c.Set("userID", agentUser.ID) - c.Set("currentUser", agentUser) - c.Set("userIsAdmin", true) - c.Set("authMethod", "agent_token") + c.Set(echoCtxKeyUserID, agentUser.ID) + c.Set(echoCtxKeyCurrentUser, agentUser) + c.Set(echoCtxKeyUserPermissions, authz.SudoPermissionSet()) + c.Set(echoCtxKeyAuthMethod, "agent_token") } func environmentSudoInternal(c echo.Context, env *models.Environment) { envUser := &models.User{ BaseModel: models.BaseModel{ID: "environment:" + env.ID}, Username: env.Name, - Roles: []string{"admin"}, } - c.Set("userID", envUser.ID) - c.Set("currentUser", envUser) - c.Set("userIsAdmin", true) - c.Set("authMethod", "environment_access_token") + c.Set(echoCtxKeyUserID, envUser.ID) + c.Set(echoCtxKeyCurrentUser, envUser) + c.Set(echoCtxKeyUserPermissions, authz.SudoPermissionSet()) + c.Set(echoCtxKeyAuthMethod, "environment_access_token") } func extractBearerOrCookieTokenInternal(c echo.Context) string { diff --git a/backend/internal/middleware/csrf_middleware.go b/backend/internal/middleware/csrf_middleware.go new file mode 100644 index 0000000000..6707e9eb2c --- /dev/null +++ b/backend/internal/middleware/csrf_middleware.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/models" + pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/labstack/echo/v4" +) + +// publicCrossOriginBypassPaths are endpoints intentionally reachable cross-origin +// by non-browser callers that authenticate with their OWN token carried in the +// path or request body (not the session cookie). Cross-origin protection must not +// block them, since a legitimate external caller (CI, webhook source) may attach +// an Origin header. Patterns use net/http ServeMux syntax, matched against URL path. +var publicCrossOriginBypassPaths = []string{ + "/api/webhooks/trigger/{token}", + "/api/auth/federated/token", +} + +// CSRFMiddleware rejects cross-origin state-changing requests to the cookie-backed +// API. It complements the SameSite=Lax session cookie with server-side origin +// verification (Sec-Fetch-Site, with an Origin/Host fallback) via the standard +// library's net/http.CrossOriginProtection. +// +// Header-credentialed requests (Bearer / X-API-Key / agent token) are not CSRF-able +// — a browser cannot attach those headers to a forged cross-origin request — so they +// are left untouched, as are non-browser clients that send no Origin header. +type CSRFMiddleware struct { + cfg *config.Config +} + +func NewCSRFMiddleware(cfg *config.Config) *CSRFMiddleware { + return &CSRFMiddleware{cfg: cfg} +} + +func (m *CSRFMiddleware) Add() echo.MiddlewareFunc { + // Edge Agent mode: skip. The agent only receives server-to-server requests + // through the edge tunnel from the manager — never from browsers. Mirrors CORSMiddleware. + if m.cfg != nil && m.cfg.EdgeAgent { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { return next(c) } + } + } + + cop := http.NewCrossOriginProtection() + for _, origin := range deriveAllowedOriginsInternal(m.cfg, nil) { + // AddTrustedOrigin expects scheme://host[:port]; deriveAllowedOriginsInternal + // already produces that form (shared with CORS). Skip anything malformed. + if err := cop.AddTrustedOrigin(origin); err != nil { + slog.Warn("CSRF: ignoring invalid trusted origin", "origin", origin, "error", err) + } + } + for _, pattern := range publicCrossOriginBypassPaths { + cop.AddInsecureBypassPattern(pattern) + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + req := c.Request() + if hasHeaderCredentialInternal(req) { + return next(c) + } + if err := cop.Check(req); err != nil { + slog.WarnContext(req.Context(), "CSRF: cross-origin request blocked", + "path", req.URL.Path, + "method", req.Method, + "origin", req.Header.Get("Origin"), + "sec_fetch_site", req.Header.Get("Sec-Fetch-Site"), + ) + return c.JSON(http.StatusForbidden, models.APIError{ + Code: models.APIErrorCodeForbidden, + Message: "Cross-origin request blocked", + }) + } + return next(c) + } + } +} + +// hasHeaderCredentialInternal reports whether the request carries a credential in a +// header that a browser cannot forge on a cross-origin request, meaning it is not +// susceptible to CSRF and should bypass the cross-origin check. +func hasHeaderCredentialInternal(req *http.Request) bool { + return strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ") || + req.Header.Get(pkgutils.HeaderApiKey) != "" || + req.Header.Get(pkgutils.HeaderAgentToken) != "" +} diff --git a/backend/internal/middleware/csrf_middleware_test.go b/backend/internal/middleware/csrf_middleware_test.go new file mode 100644 index 0000000000..c66fc0a70f --- /dev/null +++ b/backend/internal/middleware/csrf_middleware_test.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func newCSRFTestRouterInternal() *echo.Echo { + cfg := &config.Config{Environment: "production", AppUrl: "https://arcane.example.com"} + router := echo.New() + router.Use(NewCSRFMiddleware(cfg).Add()) + router.POST("/api/test", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + router.POST("/api/webhooks/trigger/:token", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + return router +} + +func TestCSRF_BlocksCrossSiteStateChange(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://evil.example.com") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestCSRF_AllowsSameOrigin(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + req.Header.Set("Sec-Fetch-Site", "same-origin") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +// Non-browser clients (curl, CLI, CI, the report's Python PoC) send no Origin or +// Sec-Fetch-Site header and must not be blocked. +func TestCSRF_AllowsNonBrowserClient(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +// A header credential cannot be forged cross-origin by a browser, so even a +// cross-site Origin must not block such a request. +func TestCSRF_SkipsHeaderCredentialedRequests(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/test", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("X-Api-Key", "some-key") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +// Public webhook trigger authenticates via its path token, not the session cookie, +// and may receive an Origin header from an external caller; it must be bypassed. +func TestCSRF_AllowsPublicBypassPaths(t *testing.T) { + router := newCSRFTestRouterInternal() + req := httptest.NewRequest(http.MethodPost, "/api/webhooks/trigger/abc", nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://ci.example.com") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/backend/internal/middleware/environment_middleware.go b/backend/internal/middleware/environment_middleware.go index f79e76439a..668eedcfcd 100644 --- a/backend/internal/middleware/environment_middleware.go +++ b/backend/internal/middleware/environment_middleware.go @@ -200,6 +200,10 @@ func (m *EnvironmentMiddleware) hasResourcePath(c echo.Context, envID string) bo } func isManagementPathInternal(suffix string) bool { + if suffix == "/activities" || strings.HasPrefix(suffix, "/activities/") { + return true + } + if strings.HasPrefix(suffix, "/notifications") { return true } @@ -342,8 +346,7 @@ func (m *EnvironmentMiddleware) proxyHTTP(c echo.Context, target string, accessT req, err := m.createProxyRequest(c, target, accessToken) if err != nil { errMessage := (&common.EnvironmentProxyRequestCreationError{Err: err}).Error() - var invalidTargetErr *common.EnvironmentInvalidProxyTargetError - if errors.As(err, &invalidTargetErr) { + if invalidTargetErr, ok := errors.AsType[*common.EnvironmentInvalidProxyTargetError](err); ok { errMessage = invalidTargetErr.Error() } return c.JSON(http.StatusInternalServerError, map[string]any{ @@ -352,7 +355,7 @@ func (m *EnvironmentMiddleware) proxyHTTP(c echo.Context, target string, accessT }) } - resp, err := m.httpClient.Do(req) //nolint:gosec // intentional proxy request to resolved remote environment URL + resp, err := m.httpClient.Do(req) if err != nil { return c.JSON(http.StatusBadGateway, map[string]any{ "success": false, diff --git a/backend/internal/middleware/environment_middleware_test.go b/backend/internal/middleware/environment_middleware_test.go index 3d3f91ed2d..3dddc1ca82 100644 --- a/backend/internal/middleware/environment_middleware_test.go +++ b/backend/internal/middleware/environment_middleware_test.go @@ -144,6 +144,51 @@ func TestEnvironmentMiddleware_KeepsNotificationEndpointsLocal(t *testing.T) { assert.True(t, localHandlerHit) } +func TestEnvironmentMiddleware_KeepsActivityEndpointsLocal(t *testing.T) { + tests := []struct { + name string + method string + route string + path string + }{ + { + name: "list activities", + method: http.MethodGet, + route: "/environments/:id/activities", + path: "/api/environments/env-edge/activities?limit=50", + }, + { + name: "stream activities", + method: http.MethodGet, + route: "/environments/:id/activities/stream", + path: "/api/environments/env-edge/activities/stream?limit=50", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware := newTestEnvironmentMiddleware() + router := echo.New() + api := attachMiddleware(router, middleware) + + localHandlerHit := false + api.Add(tt.method, tt.route, func(c echo.Context) error { + localHandlerHit = true + return c.JSON(http.StatusOK, map[string]any{"success": true}) + }) + + req := httptest.NewRequest(tt.method, tt.path, nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), "\"success\":true") + assert.True(t, localHandlerHit) + }) + } +} + func TestEnvironmentMiddleware_ProxyWebSocketRejectsEdgeTargetsWithoutTunnel(t *testing.T) { middleware := newTestEnvironmentMiddleware() e := echo.New() diff --git a/backend/internal/middleware/rate_limit_middleware.go b/backend/internal/middleware/rate_limit_middleware.go index 20cba51a69..8da33c45bd 100644 --- a/backend/internal/middleware/rate_limit_middleware.go +++ b/backend/internal/middleware/rate_limit_middleware.go @@ -146,7 +146,7 @@ func PerAgentTokenRateLimit(perMinute int, burst int) echo.MiddlewareFunc { req := c.Request() key := strings.TrimSpace(req.Header.Get("X-Arcane-Agent-Token")) if key == "" { - key = strings.TrimSpace(req.Header.Get("X-API-Key")) + key = strings.TrimSpace(req.Header.Get("X-Api-Key")) } if key == "" { return next(c) diff --git a/backend/internal/middleware/rate_limit_middleware_test.go b/backend/internal/middleware/rate_limit_middleware_test.go index 0d3768d1a0..f88525bb7d 100644 --- a/backend/internal/middleware/rate_limit_middleware_test.go +++ b/backend/internal/middleware/rate_limit_middleware_test.go @@ -91,7 +91,7 @@ func TestPerIPRateLimitForPaths_AppliesOnlyToConfiguredPaths(t *testing.T) { require.Equal(t, http.StatusOK, doReq("/limited")) require.Equal(t, http.StatusTooManyRequests, doReq("/limited")) - for i := 0; i < 10; i++ { + for range 10 { require.Equal(t, http.StatusOK, doReq("/unlimited")) } } diff --git a/backend/internal/models/activity.go b/backend/internal/models/activity.go new file mode 100644 index 0000000000..3aaa272052 --- /dev/null +++ b/backend/internal/models/activity.go @@ -0,0 +1,86 @@ +package models + +import "time" + +type ( + ActivityType string + ActivityStatus string + ActivityMessageLevel string +) + +const ( + ActivityStatusQueued ActivityStatus = "queued" + ActivityStatusRunning ActivityStatus = "running" + ActivityStatusSuccess ActivityStatus = "success" + ActivityStatusFailed ActivityStatus = "failed" + ActivityStatusCancelled ActivityStatus = "cancelled" +) + +const ( + ActivityTypeImagePull ActivityType = "image_pull" + ActivityTypeImageBuild ActivityType = "image_build" + ActivityTypeImageUpdateCheck ActivityType = "image_update_check" + ActivityTypeProjectPull ActivityType = "project_pull" + ActivityTypeProjectBuild ActivityType = "project_build" + ActivityTypeProjectDeploy ActivityType = "project_deploy" + ActivityTypeProjectRedeploy ActivityType = "project_redeploy" + ActivityTypeProjectDown ActivityType = "project_down" + ActivityTypeProjectRestart ActivityType = "project_restart" + ActivityTypeProjectDestroy ActivityType = "project_destroy" + ActivityTypeContainerStart ActivityType = "container_start" + ActivityTypeContainerStop ActivityType = "container_stop" + ActivityTypeContainerRestart ActivityType = "container_restart" + ActivityTypeContainerRedeploy ActivityType = "container_redeploy" + ActivityTypeContainerDelete ActivityType = "container_delete" + ActivityTypeVulnerabilityScan ActivityType = "vulnerability_scan" + ActivityTypeAutoUpdate ActivityType = "auto_update" + ActivityTypeSystemPrune ActivityType = "system_prune" + ActivityTypeResourceAction ActivityType = "resource_action" +) + +const ( + ActivityMessageLevelInfo ActivityMessageLevel = "info" + ActivityMessageLevelWarning ActivityMessageLevel = "warning" + ActivityMessageLevelError ActivityMessageLevel = "error" + ActivityMessageLevelSuccess ActivityMessageLevel = "success" +) + +type Activity struct { + BaseModel + + EnvironmentID string `json:"environmentId" gorm:"column:environment_id;not null;index" sortable:"true"` + Type ActivityType `json:"type" gorm:"column:type;not null;index" sortable:"true"` + Status ActivityStatus `json:"status" gorm:"column:status;not null;index" sortable:"true"` + ResourceType *string `json:"resourceType,omitempty" gorm:"column:resource_type;index" sortable:"true"` + ResourceID *string `json:"resourceId,omitempty" gorm:"column:resource_id;index"` + ResourceName *string `json:"resourceName,omitempty" gorm:"column:resource_name" sortable:"true"` + Progress *int `json:"progress,omitempty" gorm:"column:progress"` + Step string `json:"step,omitempty" gorm:"column:step"` + LatestMessage string `json:"latestMessage,omitempty" gorm:"column:latest_message"` + StartedByUserID *string `json:"startedByUserId,omitempty" gorm:"column:started_by_user_id;index"` + StartedByUsername *string `json:"startedByUsername,omitempty" gorm:"column:started_by_username"` + StartedByDisplayName *string `json:"startedByDisplayName,omitempty" gorm:"column:started_by_display_name"` + StartedAt time.Time `json:"startedAt" gorm:"column:started_at;not null" sortable:"true"` + EndedAt *time.Time `json:"endedAt,omitempty" gorm:"column:ended_at" sortable:"true"` + DurationMs *int64 `json:"durationMs,omitempty" gorm:"column:duration_ms" sortable:"true"` + Error *string `json:"error,omitempty" gorm:"column:error"` + Metadata JSON `json:"metadata,omitempty" gorm:"type:text"` +} + +func (Activity) TableName() string { + return "activities" +} + +type ActivityMessage struct { + BaseModel + + ActivityID string `json:"activityId" gorm:"column:activity_id;not null;index"` + Level ActivityMessageLevel `json:"level" gorm:"column:level;not null"` + Message string `json:"message" gorm:"column:message;not null"` + Payload JSON `json:"payload,omitempty" gorm:"type:text"` + Activity *Activity `json:"-" gorm:"foreignKey:ActivityID;constraint:OnDelete:CASCADE"` +} + +func (ActivityMessage) TableName() string { + return "activity_messages" +} diff --git a/backend/internal/models/api_key.go b/backend/internal/models/api_key.go index 861f968585..6a0c21eb1b 100644 --- a/backend/internal/models/api_key.go +++ b/backend/internal/models/api_key.go @@ -5,6 +5,8 @@ import ( ) type ApiKey struct { + BaseModel + Name string `json:"name" gorm:"column:name;not null" sortable:"true"` Description *string `json:"description,omitempty" gorm:"column:description"` KeyHash string `json:"-" gorm:"column:key_hash;not null"` @@ -14,7 +16,6 @@ type ApiKey struct { EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` ExpiresAt *time.Time `json:"expiresAt,omitempty" gorm:"column:expires_at" sortable:"true"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty" gorm:"column:last_used_at" sortable:"true"` - BaseModel } func (ApiKey) TableName() string { diff --git a/backend/internal/models/auto_update.go b/backend/internal/models/auto_update.go index c77c3cbdb7..78c8e1f86d 100644 --- a/backend/internal/models/auto_update.go +++ b/backend/internal/models/auto_update.go @@ -16,6 +16,8 @@ const ( ) type AutoUpdateRecord struct { + BaseModel + ResourceID string `json:"resourceId"` ResourceType string `json:"resourceType"` ResourceName string `json:"resourceName"` @@ -28,7 +30,6 @@ type AutoUpdateRecord struct { NewImageVersions JSON `json:"newImageVersions,omitempty" gorm:"type:text"` Error *string `json:"error,omitempty"` Details JSON `json:"details,omitempty" gorm:"type:text"` - BaseModel } func (AutoUpdateRecord) TableName() string { diff --git a/backend/internal/models/base.go b/backend/internal/models/base.go index 884d59ff28..8978aae249 100644 --- a/backend/internal/models/base.go +++ b/backend/internal/models/base.go @@ -30,7 +30,7 @@ func (m *BaseModel) BeforeUpdate(_ *gorm.DB) (err error) { return nil } -// nolint:recvcheck +//nolint:recvcheck type JSON map[string]any func (j JSON) Value() (driver.Value, error) { @@ -55,7 +55,7 @@ func (j *JSON) Scan(value any) error { } } -// nolint:recvcheck +//nolint:recvcheck type StringSlice []string func (s StringSlice) Value() (driver.Value, error) { diff --git a/backend/internal/models/container_registry.go b/backend/internal/models/container_registry.go index 07fa228b4f..5495d01822 100644 --- a/backend/internal/models/container_registry.go +++ b/backend/internal/models/container_registry.go @@ -5,6 +5,8 @@ import ( ) type ContainerRegistry struct { + BaseModel + URL string `json:"url" sortable:"true"` Username string `json:"username" sortable:"true"` Token string `json:"token"` @@ -19,7 +21,6 @@ type ContainerRegistry struct { ECRTokenGeneratedAt *time.Time `json:"ecrTokenGeneratedAt"` CreatedAt time.Time `json:"createdAt" sortable:"true"` UpdatedAt time.Time `json:"updatedAt" sortable:"true"` - BaseModel } func (ContainerRegistry) TableName() string { diff --git a/backend/internal/models/environment.go b/backend/internal/models/environment.go index ecd7b388db..76e28451d3 100644 --- a/backend/internal/models/environment.go +++ b/backend/internal/models/environment.go @@ -3,6 +3,8 @@ package models import "time" type Environment struct { + BaseModel + Name string `json:"name" sortable:"true"` ApiUrl string `json:"apiUrl" gorm:"column:api_url" sortable:"true"` Status string `json:"status" sortable:"true"` @@ -14,8 +16,6 @@ type Environment struct { ApiKeyID *string `json:"-" gorm:"column:api_key_id"` ParentEnvironmentID *string `json:"-" gorm:"column:parent_environment_id"` SwarmNodeID *string `json:"-" gorm:"column:swarm_node_id"` - - BaseModel } func (Environment) TableName() string { return "environments" } diff --git a/backend/internal/models/event.go b/backend/internal/models/event.go index a00f840835..335b840477 100644 --- a/backend/internal/models/event.go +++ b/backend/internal/models/event.go @@ -10,7 +10,7 @@ type ( ) const ( - // Event types + // EventTypeContainerStart and the constants below enumerate Arcane event types. EventTypeContainerStart EventType = "container.start" EventTypeContainerStop EventType = "container.stop" EventTypeContainerRestart EventType = "container.restart" @@ -66,11 +66,12 @@ const ( EventTypeNetworkDelete EventType = "network.delete" EventTypeNetworkError EventType = "network.error" - EventTypeSystemPrune EventType = "system.prune" - EventTypeUserLogin EventType = "user.login" - EventTypeUserLogout EventType = "user.logout" - EventTypeSystemAutoUpdate EventType = "system.auto_update" - EventTypeSystemUpgrade EventType = "system.upgrade" + EventTypeSystemPrune EventType = "system.prune" + EventTypeUserLogin EventType = "user.login" + EventTypeUserLogout EventType = "user.logout" + EventTypeFederatedExchange EventType = "federated.exchange" + EventTypeSystemAutoUpdate EventType = "system.auto_update" + EventTypeSystemUpgrade EventType = "system.upgrade" EventTypeEnvironmentCreate EventType = "environment.create" EventTypeEnvironmentConnect EventType = "environment.connect" @@ -88,7 +89,12 @@ const ( EventTypeWebhookDelete EventType = "webhook.delete" EventTypeWebhookTrigger EventType = "webhook.trigger" - // Event severities + // 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" EventSeverityError EventSeverity = "error" @@ -96,6 +102,8 @@ const ( ) type Event struct { + BaseModel + Type EventType `json:"type" sortable:"true"` Severity EventSeverity `json:"severity" sortable:"true"` Title string `json:"title" sortable:"true"` @@ -108,7 +116,6 @@ type Event struct { EnvironmentID *string `json:"environmentId,omitempty"` Metadata JSON `json:"metadata,omitempty" gorm:"type:text"` Timestamp time.Time `json:"timestamp" sortable:"true"` - BaseModel } func (Event) TableName() string { diff --git a/backend/internal/models/federated_credential.go b/backend/internal/models/federated_credential.go new file mode 100644 index 0000000000..11a6a2a9a4 --- /dev/null +++ b/backend/internal/models/federated_credential.go @@ -0,0 +1,34 @@ +package models + +import "time" + +const ( + FederatedCredentialMatchExact = "exact" + FederatedCredentialMatchGlob = "glob" +) + +type FederatedCredential struct { + BaseModel + + Name string `json:"name" gorm:"column:name;not null" sortable:"true"` + Description *string `json:"description,omitempty" gorm:"column:description"` + Enabled bool `json:"enabled" gorm:"column:enabled;not null;default:false;index" sortable:"true"` + IssuerURL string `json:"issuerUrl" gorm:"column:issuer_url;not null;index" sortable:"true"` + Audiences StringSlice `json:"audiences" gorm:"column:audiences;type:text;not null"` + SubjectClaim string `json:"subjectClaim" gorm:"column:subject_claim;not null;default:'sub'"` + SubjectMatch string `json:"subjectMatch" gorm:"column:subject_match;not null"` + MatchType string `json:"matchType" gorm:"column:match_type;not null;default:'exact'"` + RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id;index"` + IdentityUserID string `json:"identityUserId" gorm:"column:identity_user_id;not null;index"` + TokenTTLSeconds int `json:"tokenTtlSeconds" gorm:"column:token_ttl_seconds;not null;default:900"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty" gorm:"column:last_used_at" sortable:"true"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" gorm:"column:expires_at" sortable:"true"` + IdentityUser *User `json:"identityUser,omitempty" gorm:"foreignKey:IdentityUserID;constraint:OnDelete:CASCADE"` + Role *Role `json:"role,omitempty" gorm:"foreignKey:RoleID;constraint:OnDelete:RESTRICT"` + Environment *Environment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID;constraint:OnDelete:SET NULL"` +} + +func (FederatedCredential) TableName() string { + return "federated_credentials" +} diff --git a/backend/internal/models/federated_token_replay.go b/backend/internal/models/federated_token_replay.go new file mode 100644 index 0000000000..cae6c530e3 --- /dev/null +++ b/backend/internal/models/federated_token_replay.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type FederatedTokenReplay struct { + BaseModel + + TokenHash string `json:"-" gorm:"column:token_hash;not null;uniqueIndex"` + IssuerURL string `json:"issuerUrl" gorm:"column:issuer_url;not null;index"` + ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` +} + +func (FederatedTokenReplay) TableName() string { + return "federated_token_replays" +} diff --git a/backend/internal/models/git_repository.go b/backend/internal/models/git_repository.go index 13fbc28aa6..94f48ad74a 100644 --- a/backend/internal/models/git_repository.go +++ b/backend/internal/models/git_repository.go @@ -1,6 +1,8 @@ package models type GitRepository struct { + BaseModel + Name string `json:"name" sortable:"true" search:"git,repository,repo,source,version,control,github,gitlab,bitbucket"` URL string `json:"url" sortable:"true" search:"url,git,clone,remote,https,ssh"` AuthType string `json:"authType" sortable:"true" search:"auth,authentication,credentials,token,ssh,http"` // none, http, ssh @@ -10,7 +12,6 @@ type GitRepository struct { SSHHostKeyVerification string `json:"sshHostKeyVerification" gorm:"default:accept_new"` // strict, accept_new, skip Description *string `json:"description,omitempty" sortable:"true"` Enabled bool `json:"enabled" sortable:"true" search:"enabled,active,disabled"` - BaseModel } func (GitRepository) TableName() string { diff --git a/backend/internal/models/gitops_sync.go b/backend/internal/models/gitops_sync.go index 90c97fd07f..cb9dad3fa1 100644 --- a/backend/internal/models/gitops_sync.go +++ b/backend/internal/models/gitops_sync.go @@ -5,6 +5,8 @@ import ( ) type GitOpsSync struct { + BaseModel + Name string `json:"name" sortable:"true" search:"sync,gitops,automation,deploy,deployment,continuous"` EnvironmentID string `json:"environmentId" sortable:"true"` Environment *Environment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"` @@ -27,7 +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"` - BaseModel + + // 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 { diff --git a/backend/internal/models/image_build.go b/backend/internal/models/image_build.go index a34e0c2ede..3191c8e879 100644 --- a/backend/internal/models/image_build.go +++ b/backend/internal/models/image_build.go @@ -11,6 +11,8 @@ const ( ) type ImageBuild struct { + BaseModel + EnvironmentID string `json:"environmentId" gorm:"column:environment_id;index"` UserID *string `json:"userId,omitempty" gorm:"column:user_id"` Username *string `json:"username,omitempty" gorm:"column:username"` @@ -42,7 +44,6 @@ type ImageBuild struct { OutputTruncated bool `json:"outputTruncated" gorm:"column:output_truncated;default:false"` CompletedAt *time.Time `json:"completedAt,omitempty" gorm:"column:completed_at" sortable:"true"` DurationMs *int64 `json:"durationMs,omitempty" gorm:"column:duration_ms"` - BaseModel } func (ImageBuild) TableName() string { diff --git a/backend/internal/models/image_update.go b/backend/internal/models/image_update.go index da3695345a..0555195cc7 100644 --- a/backend/internal/models/image_update.go +++ b/backend/internal/models/image_update.go @@ -5,6 +5,8 @@ import ( ) type ImageUpdateRecord struct { + BaseModel + ID string `json:"id" gorm:"primaryKey;type:text"` Repository string `json:"repository"` Tag string `json:"tag"` @@ -24,8 +26,6 @@ type ImageUpdateRecord struct { UsedCredential bool `json:"usedCredential,omitempty" gorm:"column:used_credential"` NotificationSent bool `json:"notificationSent" gorm:"column:notification_sent;default:false"` - - BaseModel } func (i *ImageUpdateRecord) TableName() string { diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go index 9bc6a0e6a7..c74dc8a4a2 100644 --- a/backend/internal/models/notification.go +++ b/backend/internal/models/notification.go @@ -127,7 +127,7 @@ type SignalConfig struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` //nolint:gosec // JSON schema compatibility with external provider config + Password string `json:"password,omitempty"` Token string `json:"token,omitempty"` Source string `json:"source"` Recipients []string `json:"recipients"` @@ -151,7 +151,7 @@ type NtfyConfig struct { Port int `json:"port"` Topic string `json:"topic"` Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` //nolint:gosec // JSON schema compatibility with external provider config + Password string `json:"password,omitempty"` Title string `json:"title,omitempty"` Priority string `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` @@ -187,7 +187,7 @@ type MatrixConfig struct { Port int `json:"port,omitempty"` Rooms string `json:"rooms"` Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` //nolint:gosec // JSON schema compatibility with external provider config + Password string `json:"password,omitempty"` DisableTLSVerification bool `json:"disableTlsVerification"` Events map[NotificationEventType]bool `json:"events,omitempty"` } @@ -209,17 +209,3 @@ type GenericConfig struct { // checked (existing behaviour). SuccessBodyContains string `json:"successBodyContains,omitempty"` } - -type AppriseSettings struct { - ID uint `json:"id" gorm:"primaryKey"` - APIURL string `json:"apiUrl" gorm:"not null"` - Enabled bool `json:"enabled" gorm:"default:false"` - ImageUpdateTag string `json:"imageUpdateTag" gorm:"type:varchar(255)"` - ContainerUpdateTag string `json:"containerUpdateTag" gorm:"type:varchar(255)"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (AppriseSettings) TableName() string { - return "apprise_settings" -} diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go index 8024452155..60173079f6 100644 --- a/backend/internal/models/project.go +++ b/backend/internal/models/project.go @@ -15,6 +15,8 @@ const ( ) type Project struct { + BaseModel + Name string `json:"name" sortable:"true" gorm:"index:idx_projects_name"` DirName *string `json:"dir_name"` Path string `json:"path" sortable:"true" gorm:"uniqueIndex"` @@ -27,8 +29,6 @@ type Project struct { ImageRefsJSON string `json:"image_refs_json,omitempty" gorm:"column:image_refs_json"` IsArchived bool `json:"is_archived" gorm:"column:is_archived;default:false;index"` ArchivedAt *time.Time `json:"archived_at,omitempty" gorm:"column:archived_at"` - - BaseModel } func (Project) TableName() string { diff --git a/backend/internal/models/rbac.go b/backend/internal/models/rbac.go new file mode 100644 index 0000000000..950f5ee971 --- /dev/null +++ b/backend/internal/models/rbac.go @@ -0,0 +1,76 @@ +package models + +// Role is a named permission set. Built-in roles (Admin, Editor, Deployer, +// Viewer) are seeded by migration 054 and cannot be edited or deleted. +type Role struct { + BaseModel + + Name string `json:"name" gorm:"column:name;not null;uniqueIndex" sortable:"true"` + Description *string `json:"description,omitempty" gorm:"column:description"` + Permissions StringSlice `json:"permissions" gorm:"column:permissions;type:text;not null"` + BuiltIn bool `json:"builtIn" gorm:"column:built_in;not null;default:false" sortable:"true"` +} + +func (Role) TableName() string { return "roles" } + +// UserRoleAssignment binds a user to a role, optionally scoped to one +// environment. EnvironmentID == nil means "global" — the role's permissions +// apply across all environments AND to org-level resources. +// +// Source distinguishes manual assignments (managed by admins via the UI) from +// assignments synthesized from OIDC group mappings on every login. +type UserRoleAssignment struct { + BaseModel + + UserID string `json:"userId" gorm:"column:user_id;not null;index"` + RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id;index"` + Source string `json:"source" gorm:"column:source;not null;default:'manual'"` +} + +func (UserRoleAssignment) TableName() string { return "user_role_assignments" } + +// Assignment source values stored in UserRoleAssignment.Source. +const ( + RoleAssignmentSourceManual = "manual" + RoleAssignmentSourceOidc = "oidc" +) + +// ApiKeyPermission is one permission grant on an API key, optionally scoped to +// a single environment. Permissions are stored per-row (rather than as a JSON +// column on api_keys) so we can index by (api_key_id, permission) for fast +// lookups in the auth bridge. +type ApiKeyPermission struct { + BaseModel + + ApiKeyID string `json:"apiKeyId" gorm:"column:api_key_id;not null;index"` + Permission string `json:"permission" gorm:"column:permission;not null"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` +} + +func (ApiKeyPermission) TableName() string { return "api_key_permissions" } + +// OidcRoleMapping maps an OIDC group/claim value to a role assignment. On +// every OIDC login, the auth service replaces all source='oidc' rows on the +// user with assignments derived from the mappings whose ClaimValue matches a +// claim returned by the IdP. +// +// Source distinguishes UI/API-managed rows (the default) from env-declared +// rows reconciled at boot from OIDC_ROLE_MAPPINGS. Env-managed rows are +// read-only via the API — they can only be changed by editing the env var +// and restarting. +type OidcRoleMapping struct { + BaseModel + + ClaimValue string `json:"claimValue" gorm:"column:claim_value;not null;index"` + RoleID string `json:"roleId" gorm:"column:role_id;not null;index"` + EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"` + Source string `json:"source" gorm:"column:source;not null;default:'manual'"` +} + +func (OidcRoleMapping) TableName() string { return "oidc_role_mappings" } + +const ( + OidcMappingSourceManual = "manual" + OidcMappingSourceEnv = "env" +) diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 8e5be48097..391284c974 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -1,7 +1,6 @@ package models import ( - "encoding/json" "errors" "fmt" "reflect" @@ -13,8 +12,7 @@ import ( ) const ( - redactionMask = "XXXXXXXXXX" - keyAuthOidcConfig = "authOidcConfig" + redactionMask = "XXXXXXXXXX" ) type SettingVariable struct { @@ -75,7 +73,7 @@ type Settings struct { SwarmStackSourcesDirectory SettingVariable `key:"swarmStackSourcesDirectory,envOverride" meta:"label=Swarm Stack Sources Directory;type=text;keywords=swarm,stacks,stack,source,sources,directory,path,folder,location,storage,compose,env;category=internal;description=Configure where swarm stack source files are stored"` DiskUsagePath SettingVariable `key:"diskUsagePath" meta:"label=Disk Usage Path;type=text;keywords=disk,usage,path,storage,folder,files;category=general;description=Path used for disk usage calculations"` BaseServerURL SettingVariable `key:"baseServerUrl" meta:"label=Base Server URL;type=text;keywords=base,url,server,domain,host,endpoint,address,link;category=general;description=Set the base URL for the application"` - EnableGravatar SettingVariable `key:"enableGravatar" meta:"label=Enable Gravatar;type=boolean;keywords=gravatar,avatar,profile,picture,image,user,photo;category=general;description=Enable Gravatar profile pictures for users"` + EnableGravatar SettingVariable `key:"enableGravatar,authrequired" meta:"label=Enable Gravatar;type=boolean;keywords=gravatar,avatar,profile,picture,image,user,photo;category=general;description=Enable Gravatar profile pictures for users"` DefaultShell SettingVariable `key:"defaultShell" meta:"label=Default Shell;type=text;keywords=shell,default,shellpath,path,login;category=general;description=Default shell to use for commands"` EnvironmentHealthInterval SettingVariable `key:"environmentHealthInterval" meta:"label=Environment Health Check Interval;type=cron;keywords=environment,health,check,interval,frequency,heartbeat,status,monitoring,uptime,jobs,schedule;description=How often to check environment connectivity (cron expression)" catmeta:"id=jobschedule;title=Job Schedule;icon=jobs;url=/settings/jobs;description=Configure how often Arcane background jobs run"` ApplicationTheme SettingVariable `key:"applicationTheme,public,local" meta:"label=Application Theme;type=select;keywords=theme,appearance,style,visual,palette,background,interface,ui;category=appearance;description=Choose the overall visual theme for the application"` @@ -91,47 +89,37 @@ type Settings struct { DockerClientRefreshInterval SettingVariable `key:"dockerClientRefreshInterval" meta:"label=Docker Client Refresh Interval;type=cron;keywords=docker,client,refresh,daemon,api,version,reconnect,renegotiate,schedule;category=internal;description=How often to refresh the cached Docker client API version (cron expression)"` EventCleanupInterval SettingVariable `key:"eventCleanupInterval" meta:"label=Event Cleanup Interval;type=cron;keywords=events,cleanup,retention,interval,frequency,schedule,history,logs,jobs;description=How often to delete old events (cron expression)"` ExpiredSessionsCleanupInterval SettingVariable `key:"expiredSessionsCleanupInterval" meta:"label=Expired Sessions Cleanup Interval;type=cron;keywords=sessions,cleanup,retention,expired,revoked,interval,frequency,schedule,auth,jobs;description=How often to delete expired and old revoked sessions (cron expression)"` + ActivityHistoryRetentionDays SettingVariable `key:"activityHistoryRetentionDays" meta:"label=Activity History Retention;type=number;keywords=activity,history,retention,days,cleanup,background,tasks;category=activity;description=Delete completed Activity Center entries older than this many days. Set 0 to disable age-based cleanup." catmeta:"id=activity;title=Activity;icon=activity;url=/settings/activity;description=Configure Activity Center history and cleanup"` + ActivityHistoryMaxEntries SettingVariable `key:"activityHistoryMaxEntries" meta:"label=Activity History Limit;type=number;keywords=activity,history,limit,entries,count,cleanup,background,tasks;category=activity;description=Maximum completed Activity Center entries to keep per environment. Set 0 to disable count-based cleanup."` AutoInjectEnv SettingVariable `key:"autoInjectEnv" meta:"label=Auto Inject Env Variables;type=boolean;keywords=auto,inject,env,environment,variables,interpolation;category=internal;description=Automatically inject project .env variables into all containers (default: false)"` - // Deprecated: Use the granular prune mode settings instead. - PruneMode SettingVariable `key:"dockerPruneMode,internal,deprecated" meta:"label=Legacy Docker Prune Action;type=select;keywords=prune,cleanup,clean,remove,delete,unused,dangling,space,disk,legacy;category=internal;description=Legacy prune mode retained for compatibility and migration"` - DefaultDeployPullPolicy SettingVariable `key:"defaultDeployPullPolicy" meta:"label=Default Deploy Pull Policy;type=select;keywords=deploy,pull,policy,compose,up,missing,always;category=internal;description=Default image pull policy when deploying projects"` - ScheduledPruneEnabled SettingVariable `key:"scheduledPruneEnabled" meta:"label=Scheduled Prune Enabled;type=boolean;keywords=prune,cleanup,maintenance,schedule,automatic;category=internal;description=Enable scheduled pruning of unused Docker resources"` - ScheduledPruneInterval SettingVariable `key:"scheduledPruneInterval" meta:"label=Scheduled Prune Interval;type=cron;keywords=prune,cleanup,interval,minutes,schedule;category=internal;description=How often to run scheduled prunes (cron expression)"` - GitopsSyncInterval SettingVariable `key:"gitopsSyncInterval" meta:"label=GitOps Sync Interval;type=cron;keywords=gitops,sync,interval,frequency,schedule,repository;category=internal;description=How often to run GitOps synchronization checks (cron expression)"` - // Deprecated: Use pruneContainerMode instead. - ScheduledPruneContainers SettingVariable `key:"scheduledPruneContainers,deprecated" meta:"label=Legacy Scheduled Prune Containers;type=boolean;keywords=prune,containers,cleanup,maintenance,legacy;category=internal;description=Legacy boolean container prune flag retained for migration compatibility"` - // Deprecated: Use pruneImageMode instead. - ScheduledPruneImages SettingVariable `key:"scheduledPruneImages,deprecated" meta:"label=Legacy Scheduled Prune Images;type=boolean;keywords=prune,images,cleanup,maintenance,legacy;category=internal;description=Legacy boolean image prune flag retained for migration compatibility"` - // Deprecated: Use pruneVolumeMode instead. - ScheduledPruneVolumes SettingVariable `key:"scheduledPruneVolumes,deprecated" meta:"label=Legacy Scheduled Prune Volumes;type=boolean;keywords=prune,volumes,cleanup,maintenance,legacy;category=internal;description=Legacy boolean volume prune flag retained for migration compatibility"` - // Deprecated: Use pruneNetworkMode instead. - ScheduledPruneNetworks SettingVariable `key:"scheduledPruneNetworks,deprecated" meta:"label=Legacy Scheduled Prune Networks;type=boolean;keywords=prune,networks,cleanup,maintenance,legacy;category=internal;description=Legacy boolean network prune flag retained for migration compatibility"` - // Deprecated: Use pruneBuildCacheMode instead. - ScheduledPruneBuildCache SettingVariable `key:"scheduledPruneBuildCache,deprecated" meta:"label=Legacy Scheduled Prune Build Cache;type=boolean;keywords=prune,build cache,cleanup,maintenance,legacy;category=internal;description=Legacy boolean build cache prune flag retained for migration compatibility"` - PruneContainerMode SettingVariable `key:"pruneContainerMode" meta:"label=Prune Containers;type=select;keywords=prune,containers,cleanup,maintenance,mode,older,stopped;category=internal;description=Select how containers should be pruned when the scheduled prune job runs"` - PruneContainerUntil SettingVariable `key:"pruneContainerUntil" meta:"label=Container Age Filter;type=text;keywords=prune,containers,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled container prune when mode is olderThan"` - PruneImageMode SettingVariable `key:"pruneImageMode" meta:"label=Prune Images;type=select;keywords=prune,images,cleanup,maintenance,mode,dangling,all,older;category=internal;description=Select how images should be pruned when the scheduled prune job runs"` - PruneImageUntil SettingVariable `key:"pruneImageUntil" meta:"label=Image Age Filter;type=text;keywords=prune,images,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled image prune when mode is olderThan"` - PruneVolumeMode SettingVariable `key:"pruneVolumeMode" meta:"label=Prune Volumes;type=select;keywords=prune,volumes,cleanup,maintenance,mode,anonymous,named;category=internal;description=Select how volumes should be pruned when the scheduled prune job runs"` - PruneNetworkMode SettingVariable `key:"pruneNetworkMode" meta:"label=Prune Networks;type=select;keywords=prune,networks,cleanup,maintenance,mode,unused,older;category=internal;description=Select how networks should be pruned when the scheduled prune job runs"` - PruneNetworkUntil SettingVariable `key:"pruneNetworkUntil" meta:"label=Network Age Filter;type=text;keywords=prune,networks,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled network prune when mode is olderThan"` - PruneBuildCacheMode SettingVariable `key:"pruneBuildCacheMode" meta:"label=Prune Build Cache;type=select;keywords=prune,build cache,cleanup,maintenance,mode,unused,all,older;category=internal;description=Select how build cache should be pruned when the scheduled prune job runs"` - PruneBuildCacheUntil SettingVariable `key:"pruneBuildCacheUntil" meta:"label=Build Cache Age Filter;type=text;keywords=prune,build cache,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled build cache prune when mode is olderThan"` - AutoHealEnabled SettingVariable `key:"autoHealEnabled" meta:"label=Auto Heal;type=boolean;keywords=auto,heal,health,restart,unhealthy,recovery,container,healthcheck;category=internal;description=Automatically restart containers that become unhealthy"` - AutoHealInterval SettingVariable `key:"autoHealInterval" meta:"label=Auto Heal Interval;type=cron;keywords=auto,heal,interval,frequency,schedule,health,jobs;description=How often to check container health (cron expression)" catmeta:"id=jobschedule"` - AutoHealExcludedContainers SettingVariable `key:"autoHealExcludedContainers" meta:"label=Auto Heal Excluded Containers;type=text;keywords=auto,heal,exclude,containers,ignore,skip,health;category=internal;description=Comma-separated list of containers to exclude from auto-heal"` - AutoHealMaxRestarts SettingVariable `key:"autoHealMaxRestarts" meta:"label=Auto Heal Max Restarts;type=number;keywords=auto,heal,max,restarts,limit,loop,protection;category=internal;description=Maximum auto-heal restarts per container within the restart window (default: 5)"` - AutoHealRestartWindow SettingVariable `key:"autoHealRestartWindow" meta:"label=Auto Heal Restart Window;type=number;keywords=auto,heal,restart,window,minutes,cooldown,protection;category=internal;description=Time window in minutes for counting auto-heal restarts (default: 30)"` - MaxImageUploadSize SettingVariable `key:"maxImageUploadSize" meta:"label=Max Image Upload Size;type=number;keywords=upload,size,limit,maximum,image,tar,file,megabytes,mb,storage;category=internal;description=Maximum size in MB for image archive uploads (default: 500)"` - GitSyncMaxFiles SettingVariable `key:"gitSyncMaxFiles,envOverride" meta:"label=Git Sync Max Files;type=number;keywords=git,sync,files,limit,repository,compose,gitops;category=general;description=Maximum number of repository files copied during a Git sync. Set 0 to disable the environment cap (default: 500)"` - GitSyncMaxTotalSizeMb SettingVariable `key:"gitSyncMaxTotalSizeMb,envOverride" meta:"label=Git Sync Max Total Size (MB);type=number;keywords=git,sync,size,limit,repository,compose,gitops,mb;category=general;description=Maximum combined size in MB for files copied during a Git sync. Set 0 to disable the environment cap (default: 50)"` - GitSyncMaxBinarySizeMb SettingVariable `key:"gitSyncMaxBinarySizeMb,envOverride" meta:"label=Git Sync Max Binary Size (MB);type=number;keywords=git,sync,binary,size,limit,repository,compose,gitops,mb;category=general;description=Maximum size in MB for a single binary file copied during a Git sync. Set 0 to disable the environment cap (default: 10)"` - DockerHost SettingVariable `key:"dockerHost,authrequired,envOverride" meta:"label=Docker Host;type=text;keywords=docker,host,daemon,socket,unix,remote;category=internal;description=URI for Docker daemon"` - BuildProvider SettingVariable `key:"buildProvider,envOverride" meta:"label=Build Provider;type=select;keywords=build,buildkit,depot,provider,remote,local;category=build;description=Default build provider (local or depot)" catmeta:"id=build;title=Build;icon=code;url=/settings/builds;description=Configure BuildKit and Depot build settings"` - BuildsDirectory SettingVariable `key:"buildsDirectory,envOverride" meta:"label=Builds Directory;type=text;keywords=builds,directory,path,workspace,context;category=build;description=Root directory for manual build workspaces"` - BuildTimeout SettingVariable `key:"buildTimeout,envOverride" meta:"label=Build Timeout;type=number;keywords=build,timeout,seconds,buildkit;category=build;description=Timeout for BuildKit builds in seconds (default: 1800 = 30 minutes)"` - DepotProjectId SettingVariable `key:"depotProjectId,envOverride" meta:"label=Depot Project ID;type=text;keywords=depot,project,id,build,provider;category=build;description=Depot project identifier"` - DepotToken SettingVariable `key:"depotToken,envOverride,sensitive" meta:"label=Depot Token;type=password;keywords=depot,token,api,secret,build,provider;category=build;description=Depot API token"` + DefaultDeployPullPolicy SettingVariable `key:"defaultDeployPullPolicy" meta:"label=Default Deploy Pull Policy;type=select;keywords=deploy,pull,policy,compose,up,missing,always;category=internal;description=Default image pull policy when deploying projects"` + ScheduledPruneEnabled SettingVariable `key:"scheduledPruneEnabled" meta:"label=Scheduled Prune Enabled;type=boolean;keywords=prune,cleanup,maintenance,schedule,automatic;category=internal;description=Enable scheduled pruning of unused Docker resources"` + ScheduledPruneInterval SettingVariable `key:"scheduledPruneInterval" meta:"label=Scheduled Prune Interval;type=cron;keywords=prune,cleanup,interval,minutes,schedule;category=internal;description=How often to run scheduled prunes (cron expression)"` + GitopsSyncInterval SettingVariable `key:"gitopsSyncInterval" meta:"label=GitOps Sync Interval;type=cron;keywords=gitops,sync,interval,frequency,schedule,repository;category=internal;description=How often to run GitOps synchronization checks (cron expression)"` + PruneContainerMode SettingVariable `key:"pruneContainerMode" meta:"label=Prune Containers;type=select;keywords=prune,containers,cleanup,maintenance,mode,older,stopped;category=internal;description=Select how containers should be pruned when the scheduled prune job runs"` + PruneContainerUntil SettingVariable `key:"pruneContainerUntil" meta:"label=Container Age Filter;type=text;keywords=prune,containers,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled container prune when mode is olderThan"` + PruneImageMode SettingVariable `key:"pruneImageMode" meta:"label=Prune Images;type=select;keywords=prune,images,cleanup,maintenance,mode,dangling,all,older;category=internal;description=Select how images should be pruned when the scheduled prune job runs"` + PruneImageUntil SettingVariable `key:"pruneImageUntil" meta:"label=Image Age Filter;type=text;keywords=prune,images,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled image prune when mode is olderThan"` + PruneVolumeMode SettingVariable `key:"pruneVolumeMode" meta:"label=Prune Volumes;type=select;keywords=prune,volumes,cleanup,maintenance,mode,anonymous,named;category=internal;description=Select how volumes should be pruned when the scheduled prune job runs"` + PruneNetworkMode SettingVariable `key:"pruneNetworkMode" meta:"label=Prune Networks;type=select;keywords=prune,networks,cleanup,maintenance,mode,unused,older;category=internal;description=Select how networks should be pruned when the scheduled prune job runs"` + PruneNetworkUntil SettingVariable `key:"pruneNetworkUntil" meta:"label=Network Age Filter;type=text;keywords=prune,networks,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled network prune when mode is olderThan"` + PruneBuildCacheMode SettingVariable `key:"pruneBuildCacheMode" meta:"label=Prune Build Cache;type=select;keywords=prune,build cache,cleanup,maintenance,mode,unused,all,older;category=internal;description=Select how build cache should be pruned when the scheduled prune job runs"` + PruneBuildCacheUntil SettingVariable `key:"pruneBuildCacheUntil" meta:"label=Build Cache Age Filter;type=text;keywords=prune,build cache,cleanup,maintenance,until,older,duration;category=internal;description=Duration threshold for scheduled build cache prune when mode is olderThan"` + AutoHealEnabled SettingVariable `key:"autoHealEnabled" meta:"label=Auto Heal;type=boolean;keywords=auto,heal,health,restart,unhealthy,recovery,container,healthcheck;category=internal;description=Automatically restart containers that become unhealthy"` + AutoHealInterval SettingVariable `key:"autoHealInterval" meta:"label=Auto Heal Interval;type=cron;keywords=auto,heal,interval,frequency,schedule,health,jobs;description=How often to check container health (cron expression)" catmeta:"id=jobschedule"` + AutoHealExcludedContainers SettingVariable `key:"autoHealExcludedContainers" meta:"label=Auto Heal Excluded Containers;type=text;keywords=auto,heal,exclude,containers,ignore,skip,health;category=internal;description=Comma-separated list of containers to exclude from auto-heal"` + AutoHealMaxRestarts SettingVariable `key:"autoHealMaxRestarts" meta:"label=Auto Heal Max Restarts;type=number;keywords=auto,heal,max,restarts,limit,loop,protection;category=internal;description=Maximum auto-heal restarts per container within the restart window (default: 5)"` + AutoHealRestartWindow SettingVariable `key:"autoHealRestartWindow" meta:"label=Auto Heal Restart Window;type=number;keywords=auto,heal,restart,window,minutes,cooldown,protection;category=internal;description=Time window in minutes for counting auto-heal restarts (default: 30)"` + MaxImageUploadSize SettingVariable `key:"maxImageUploadSize" meta:"label=Max Image Upload Size;type=number;keywords=upload,size,limit,maximum,image,tar,file,megabytes,mb,storage;category=internal;description=Maximum size in MB for image archive uploads (default: 500)"` + GitSyncMaxFiles SettingVariable `key:"gitSyncMaxFiles,envOverride" meta:"label=Git Sync Max Files;type=number;keywords=git,sync,files,limit,repository,compose,gitops;category=general;description=Maximum number of repository files copied during a Git sync. Set 0 to disable the environment cap (default: 500)"` + GitSyncMaxTotalSizeMb SettingVariable `key:"gitSyncMaxTotalSizeMb,envOverride" meta:"label=Git Sync Max Total Size (MB);type=number;keywords=git,sync,size,limit,repository,compose,gitops,mb;category=general;description=Maximum combined size in MB for files copied during a Git sync. Set 0 to disable the environment cap (default: 50)"` + GitSyncMaxBinarySizeMb SettingVariable `key:"gitSyncMaxBinarySizeMb,envOverride" meta:"label=Git Sync Max Binary Size (MB);type=number;keywords=git,sync,binary,size,limit,repository,compose,gitops,mb;category=general;description=Maximum size in MB for a single binary file copied during a Git sync. Set 0 to disable the environment cap (default: 10)"` + DockerHost SettingVariable `key:"dockerHost,authrequired,envOverride" meta:"label=Docker Host;type=text;keywords=docker,host,daemon,socket,unix,remote;category=internal;description=URI for Docker daemon"` + BuildProvider SettingVariable `key:"buildProvider,envOverride" meta:"label=Build Provider;type=select;keywords=build,buildkit,depot,provider,remote,local;category=build;description=Default build provider (local or depot)" catmeta:"id=build;title=Build;icon=code;url=/settings/builds;description=Configure BuildKit and Depot build settings"` + BuildsDirectory SettingVariable `key:"buildsDirectory,envOverride" meta:"label=Builds Directory;type=text;keywords=builds,directory,path,workspace,context;category=build;description=Root directory for manual build workspaces"` + BuildTimeout SettingVariable `key:"buildTimeout,envOverride" meta:"label=Build Timeout;type=number;keywords=build,timeout,seconds,buildkit;category=build;description=Timeout for BuildKit builds in seconds (default: 1800 = 30 minutes)"` + DepotProjectId SettingVariable `key:"depotProjectId,envOverride" meta:"label=Depot Project ID;type=text;keywords=depot,project,id,build,provider;category=build;description=Depot project identifier"` + DepotToken SettingVariable `key:"depotToken,envOverride,sensitive" meta:"label=Depot Token;type=password;keywords=depot,token,api,secret,build,provider;category=build;description=Depot API token"` // Authentication and security categories AuthLocalEnabled SettingVariable `key:"authLocalEnabled,public" meta:"label=Local Authentication;type=boolean;keywords=local,auth,authentication,username,password,login,credentials;category=authentication;description=Enable local username/password authentication" catmeta:"id=authentication;title=Authentication;icon=lock;url=/settings/authentication;description=Manage authentication providers, password policy, and session behavior"` @@ -150,7 +138,8 @@ type Settings struct { TrivyConcurrentScanContainers SettingVariable `key:"trivyConcurrentScanContainers,envOverride" meta:"label=Trivy Concurrent Scan Containers;type=number;keywords=trivy,concurrent,scan,containers,parallel,workers,limit,security;category=security;description=Maximum number of concurrent Trivy scan containers for manual and scheduled scans. Minimum 1"` TrivyConfig SettingVariable `key:"trivyConfig" meta:"label=Trivy Config (YAML);type=textarea;keywords=trivy,config,yaml,configuration,scanner,settings;category=security;description=Trivy configuration file content in YAML format"` TrivyIgnore SettingVariable `key:"trivyIgnore" meta:"label=.trivyignore;type=textarea;keywords=trivy,ignore,ignorefile,vulnerabilities,exceptions,exclusions;category=security;description=Trivy ignore file content - one vulnerability ID per line"` - AuthOidcConfig SettingVariable `key:"authOidcConfig,sensitive,deprecated" meta:"label=OIDC Config;type=text;keywords=oidc,config,client,id,issuer,secret,oauth;category=authentication;description=OIDC provider configuration (deprecated - use individual fields)"` + 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"` + 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"` @@ -161,8 +150,7 @@ type Settings struct { OidcJwksEndpoint SettingVariable `key:"oidcJwksEndpoint,envOverride" meta:"label=OIDC JWKS Endpoint;type=text;keywords=oidc,jwks,keys,endpoint,oauth,openid;category=authentication;description=Override OIDC JWKS endpoint"` OidcDeviceAuthorizationEndpoint SettingVariable `key:"oidcDeviceAuthorizationEndpoint,envOverride" meta:"label=OIDC Device Authorization Endpoint;type=text;keywords=oidc,device,authorization,endpoint,oauth,openid,cli;category=authentication;description=Override OIDC device authorization endpoint for CLI authentication"` OidcScopes SettingVariable `key:"oidcScopes,authrequired,envOverride" meta:"label=OIDC Scopes;type=text;keywords=oidc,scopes,oauth,openid,permissions;category=authentication;description=OIDC scopes to request"` - OidcAdminClaim SettingVariable `key:"oidcAdminClaim,authrequired,envOverride" meta:"label=OIDC Admin Claim;type=text;keywords=oidc,admin,claim,role,group;category=authentication;description=Claim name for admin role mapping"` - OidcAdminValue SettingVariable `key:"oidcAdminValue,authrequired,envOverride" meta:"label=OIDC Admin Value;type=text;keywords=oidc,admin,value,role,group;category=authentication;description=Claim value that grants admin access"` + OidcGroupsClaim SettingVariable `key:"oidcGroupsClaim,authrequired,envOverride" meta:"label=OIDC Groups Claim;type=text;keywords=oidc,groups,claim,role,mapping,rbac;category=authentication;description=Claim name to read group memberships from for role mapping (default: groups)"` OidcSkipTlsVerify SettingVariable `key:"oidcSkipTlsVerify,authrequired,envOverride" meta:"label=OIDC Skip TLS Verify;type=boolean;keywords=oidc,tls,verify,skip,insecure;category=authentication;description=Skip TLS verification for OIDC provider"` OidcAutoRedirectToProvider SettingVariable `key:"oidcAutoRedirectToProvider,public,envOverride" meta:"label=OIDC Auto Redirect;type=boolean;keywords=oidc,auto,redirect,automatic,login,provider,sso;category=authentication;description=Automatically redirect to OIDC provider on login page"` OidcMergeAccounts SettingVariable `key:"oidcMergeAccounts,authrequired,envOverride" meta:"label=OIDC Account Merging;type=boolean;keywords=oidc,merge,link,accounts,email,match,existing,users,combine;category=authentication;description=Allow OIDC logins to merge with existing accounts by email"` @@ -188,6 +176,8 @@ type Settings struct { // API Keys category (admin management page - no actual settings) ApiKeysCategoryPlaceholder SettingVariable `key:"apiKeysCategory,internal" meta:"label=API Keys;type=internal;keywords=api,keys,tokens,authentication,access,programmatic,integration;category=apikeys;description=Manage API keys for programmatic access" catmeta:"id=apikeys;title=API Keys;icon=apikey;url=/settings/api-keys;description=Create and manage API keys for programmatic access to Arcane"` + FederatedCredentialsCategoryPlaceholder SettingVariable `key:"federatedCredentialsCategory,internal" meta:"label=Federated Credentials;type=internal;keywords=federated,credentials,workload,identity,oidc,token exchange,ci,github,gitlab;category=authentication;description=Manage workload identity federation credentials"` + // Webhooks category (management page - no actual settings) WebhooksCategoryPlaceholder SettingVariable `key:"webhooksCategory,internal" meta:"label=Webhooks;type=internal;keywords=webhooks,trigger,inbound,http,container,stack,gitops,updater,automation,ci,cd;category=webhooks;description=Manage inbound webhooks to trigger updates" catmeta:"id=webhooks;title=Webhooks;icon=globe;url=/settings/webhooks;description=Create and manage inbound webhooks to trigger container, stack, or GitOps updates"` @@ -210,7 +200,7 @@ func buildSettingsFieldCacheInternal() { ordered := make([]settingFieldMeta, 0, rt.NumField()) byKey := make(map[string]settingFieldMeta, rt.NumField()) - for i := 0; i < rt.NumField(); i++ { + for i := range rt.NumField() { field := rt.Field(i) key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") if key == "" { @@ -268,7 +258,7 @@ func (s *Settings) ToSettingVariableSlice(visibility SettingVisibility, redactSe } value := cfgValue.Field(field.index).FieldByName("Value").String() - value = redactSettingValue(field.key, value, field.attrs, redactSensitiveValues) + value = redactSettingValue(value, field.attrs, redactSensitiveValues) settingVariable := SettingVariable{ Key: field.key, @@ -339,23 +329,11 @@ func (s *Settings) UpdateField(key string, value string, noSensitive bool) error } // helper keeps redaction logic in one place; behavior unchanged -func redactSettingValue(key, value, attrs string, redact bool) string { +func redactSettingValue(value, attrs string, redact bool) string { if value == "" || !redact || !strings.Contains(attrs, "sensitive") { return value } - if key == keyAuthOidcConfig { - var cfg OidcConfig - if err := json.Unmarshal([]byte(value), &cfg); err == nil { - cfg.ClientSecret = "" - if redacted, err := cfg.MarshalDocument(); err == nil { - return string(redacted) - } - return redactionMask - } - return redactionMask - } - return redactionMask } @@ -387,7 +365,7 @@ func (e SettingSensitiveForbiddenError) Is(target error) bool { type OidcConfig struct { ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` //nolint:gosec // OIDC schema compatibility requires this field name + ClientSecret string `json:"clientSecret"` IssuerURL string `json:"issuerUrl"` Scopes string `json:"scopes"` @@ -397,44 +375,10 @@ type OidcConfig struct { JwksURI string `json:"jwksUri,omitempty"` DeviceAuthorizationEndpoint string `json:"deviceAuthorizationEndpoint,omitempty"` - // Admin mapping: evaluate this claim to grant admin. - // Examples: - // - adminClaim: "admin", adminValue: "true" (boolean or string "true") - // - adminClaim: "roles", adminValue: "admin" (array membership) - // - adminClaim: "realm_access.roles", adminValue: "admin" (Keycloak) - AdminClaim string `json:"adminClaim,omitempty"` - AdminValue string `json:"adminValue,omitempty"` + // GroupsClaim is the claim path Arcane reads group memberships from on + // every OIDC login. Matched against oidc_role_mappings to produce role + // assignments. Default: "groups". + GroupsClaim string `json:"groupsClaim,omitempty"` SkipTlsVerify bool `json:"skipTlsVerify"` } - -// MarshalDocument preserves the legacy json.Marshal(OidcConfig) document shape, -// including omitempty behavior for optional string fields, while avoiding gosec -// false positives on the clientSecret field. -func (c OidcConfig) MarshalDocument() ([]byte, error) { - doc := map[string]any{ - "clientId": c.ClientID, - "clientSecret": c.ClientSecret, - "issuerUrl": c.IssuerURL, - "scopes": c.Scopes, - "skipTlsVerify": c.SkipTlsVerify, - } - - addOptionalStringFieldInternal(doc, "authorizationEndpoint", c.AuthorizationEndpoint) - addOptionalStringFieldInternal(doc, "tokenEndpoint", c.TokenEndpoint) - addOptionalStringFieldInternal(doc, "userinfoEndpoint", c.UserinfoEndpoint) - addOptionalStringFieldInternal(doc, "jwksUri", c.JwksURI) - addOptionalStringFieldInternal(doc, "deviceAuthorizationEndpoint", c.DeviceAuthorizationEndpoint) - addOptionalStringFieldInternal(doc, "adminClaim", c.AdminClaim) - addOptionalStringFieldInternal(doc, "adminValue", c.AdminValue) - - return json.Marshal(doc) -} - -func addOptionalStringFieldInternal(doc map[string]any, key, value string) { - if value == "" { - return - } - - doc[key] = value -} diff --git a/backend/internal/models/settings_test.go b/backend/internal/models/settings_test.go index 156ce15c99..81bdd2ee25 100644 --- a/backend/internal/models/settings_test.go +++ b/backend/internal/models/settings_test.go @@ -1,48 +1,11 @@ package models import ( - "encoding/json" "testing" "github.com/stretchr/testify/require" ) -func TestOidcConfig_MarshalDocument_PreservesOmitemptySemantics(t *testing.T) { - config := OidcConfig{ - ClientID: "client-id", - ClientSecret: "client-secret", - IssuerURL: "https://issuer.example", - Scopes: "openid email profile", - AuthorizationEndpoint: "", - TokenEndpoint: "https://issuer.example/token", - UserinfoEndpoint: "", - JwksURI: "https://issuer.example/jwks", - AdminClaim: "", - AdminValue: "admins", - SkipTlsVerify: true, - } - - data, err := config.MarshalDocument() - require.NoError(t, err) - - var doc map[string]any - require.NoError(t, json.Unmarshal(data, &doc)) - - require.Equal(t, "client-id", doc["clientId"]) - require.Equal(t, "client-secret", doc["clientSecret"]) - require.Equal(t, "https://issuer.example", doc["issuerUrl"]) - require.Equal(t, "openid email profile", doc["scopes"]) - require.Equal(t, true, doc["skipTlsVerify"]) - - require.NotContains(t, doc, "authorizationEndpoint") - require.NotContains(t, doc, "userinfoEndpoint") - require.NotContains(t, doc, "adminClaim") - - require.Equal(t, "https://issuer.example/token", doc["tokenEndpoint"]) - require.Equal(t, "https://issuer.example/jwks", doc["jwksUri"]) - require.Equal(t, "admins", doc["adminValue"]) -} - func TestSettings_ToSettingVariableSlice_Visibility(t *testing.T) { settings := &Settings{ ApplicationTheme: SettingVariable{Value: "default"}, @@ -57,8 +20,7 @@ func TestSettings_ToSettingVariableSlice_Visibility(t *testing.T) { OidcClientId: SettingVariable{Value: "client-id"}, OidcIssuerUrl: SettingVariable{Value: "https://issuer.example"}, OidcScopes: SettingVariable{Value: "openid email profile"}, - OidcAdminClaim: SettingVariable{Value: "groups"}, - OidcAdminValue: SettingVariable{Value: "_arcane_admins"}, + OidcGroupsClaim: SettingVariable{Value: "groups"}, OidcSkipTlsVerify: SettingVariable{Value: "false"}, OidcMergeAccounts: SettingVariable{Value: "true"}, MobileNavigationMode: SettingVariable{Value: "floating"}, @@ -85,13 +47,12 @@ func TestSettings_ToSettingVariableSlice_Visibility(t *testing.T) { require.Contains(t, nonAdminKeys, "oidcClientId") require.Contains(t, nonAdminKeys, "oidcIssuerUrl") require.Contains(t, nonAdminKeys, "oidcScopes") - require.Contains(t, nonAdminKeys, "oidcAdminClaim") - require.Contains(t, nonAdminKeys, "oidcAdminValue") + require.Contains(t, nonAdminKeys, "oidcGroupsClaim") require.Contains(t, nonAdminKeys, "oidcSkipTlsVerify") require.Contains(t, nonAdminKeys, "oidcMergeAccounts") require.Contains(t, nonAdminKeys, "mobileNavigationMode") require.Contains(t, nonAdminKeys, "keyboardShortcutsEnabled") - require.NotContains(t, nonAdminKeys, "enableGravatar") + require.Contains(t, nonAdminKeys, "enableGravatar") require.NotContains(t, nonAdminKeys, "baseServerUrl") require.NotContains(t, nonAdminKeys, "defaultShell") require.NotContains(t, nonAdminKeys, "oidcClientSecret") diff --git a/backend/internal/models/template.go b/backend/internal/models/template.go index 512edd1874..16c9d93033 100644 --- a/backend/internal/models/template.go +++ b/backend/internal/models/template.go @@ -2,6 +2,7 @@ package models type TemplateRegistry struct { BaseModel + Name string `json:"name"` URL string `json:"url"` Enabled bool `json:"enabled"` @@ -10,6 +11,7 @@ type TemplateRegistry struct { type ComposeTemplate struct { BaseModel + Name string `json:"name"` Description string `json:"description"` Content string `json:"content" gorm:"type:text"` diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index a7558b6965..e9e9ea948e 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -5,21 +5,22 @@ import ( ) type User struct { - Username string `json:"username" sortable:"true"` - PasswordHash string `json:"-" gorm:"column:password_hash"` - DisplayName *string `json:"displayName,omitempty" gorm:"column:display_name" sortable:"true"` - Email *string `json:"email,omitempty" sortable:"true"` - Roles StringSlice `json:"roles" gorm:"type:text"` - OidcSubjectId *string `json:"oidcSubjectId,omitempty" gorm:"column:oidc_subject_id"` - LastLogin *time.Time `json:"lastLogin,omitempty" gorm:"column:last_login" sortable:"true"` - Locale *string `json:"locale,omitempty" gorm:"column:locale"` - RequiresPasswordChange bool `json:"requiresPasswordChange" gorm:"column:requires_password_change"` + BaseModel + + Username string `json:"username" sortable:"true"` + PasswordHash string `json:"-" gorm:"column:password_hash"` + DisplayName *string `json:"displayName,omitempty" gorm:"column:display_name" sortable:"true"` + Email *string `json:"email,omitempty" sortable:"true"` + OidcSubjectId *string `json:"oidcSubjectId,omitempty" gorm:"column:oidc_subject_id"` + LastLogin *time.Time `json:"lastLogin,omitempty" gorm:"column:last_login" sortable:"true"` + Locale *string `json:"locale,omitempty" gorm:"column:locale"` + RequiresPasswordChange bool `json:"requiresPasswordChange" gorm:"column:requires_password_change"` + IsServiceAccount bool `json:"isServiceAccount" gorm:"column:is_service_account;not null;default:false"` // OIDC provider tokens OidcAccessToken *string `json:"-" gorm:"type:text"` OidcRefreshToken *string `json:"-" gorm:"type:text"` OidcAccessTokenExpiresAt *time.Time `json:"-"` - BaseModel } func (User) TableName() string { diff --git a/backend/internal/models/user_session.go b/backend/internal/models/user_session.go index 3b6d155d73..7da80280c7 100644 --- a/backend/internal/models/user_session.go +++ b/backend/internal/models/user_session.go @@ -2,16 +2,26 @@ package models import "time" +const ( + UserSessionSourceLocal = "local" + UserSessionSourceOidc = "oidc" + UserSessionSourceFederated = "federated" +) + type UserSession struct { BaseModel - UserID string `json:"userId" gorm:"column:user_id;not null;index"` - User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` - RefreshTokenHash string `json:"-" gorm:"column:refresh_token_hash;not null;uniqueIndex"` - UserAgent *string `json:"userAgent,omitempty" gorm:"column:user_agent"` - IPAddress *string `json:"ipAddress,omitempty" gorm:"column:ip_address"` - LastUsedAt time.Time `json:"lastUsedAt" gorm:"column:last_used_at;not null"` - ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` - RevokedAt *time.Time `json:"revokedAt,omitempty" gorm:"column:revoked_at"` + + UserID string `json:"userId" gorm:"column:user_id;not null;index"` + User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` + RefreshTokenHash string `json:"-" gorm:"column:refresh_token_hash;not null;uniqueIndex"` + UserAgent *string `json:"userAgent,omitempty" gorm:"column:user_agent"` + IPAddress *string `json:"ipAddress,omitempty" gorm:"column:ip_address"` + Source string `json:"source,omitempty" gorm:"column:source"` + FederatedCredentialID *string `json:"federatedCredentialId,omitempty" gorm:"column:federated_credential_id;index"` + FederatedCredential *FederatedCredential `json:"federatedCredential,omitempty" gorm:"foreignKey:FederatedCredentialID;constraint:OnDelete:SET NULL"` + LastUsedAt time.Time `json:"lastUsedAt" gorm:"column:last_used_at;not null"` + ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at;not null;index"` + RevokedAt *time.Time `json:"revokedAt,omitempty" gorm:"column:revoked_at"` } func (UserSession) TableName() string { diff --git a/backend/internal/models/volume_backup.go b/backend/internal/models/volume_backup.go index a4df4768a3..5384860ccc 100644 --- a/backend/internal/models/volume_backup.go +++ b/backend/internal/models/volume_backup.go @@ -8,9 +8,11 @@ import ( type VolumeBackup struct { BaseModel + VolumeName string `json:"volumeName" gorm:"column:volume_name;index"` Size int64 `json:"size" gorm:"column:size"` CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` + ActivityID *string `json:"activityId,omitempty" gorm:"-"` } func (*VolumeBackup) TableName() string { diff --git a/backend/internal/models/vulnerability_ignore.go b/backend/internal/models/vulnerability_ignore.go index 59f43297cd..4c1e530f76 100644 --- a/backend/internal/models/vulnerability_ignore.go +++ b/backend/internal/models/vulnerability_ignore.go @@ -52,8 +52,8 @@ func (v *VulnerabilityIgnore) BeforeCreate(db *gorm.DB) error { return nil } -// VulnerabilityIgnoreCompositeKey generates a composite key for deduplication -// This helps prevent duplicate ignore records for the same vulnerability +// CompositeKey generates a composite key for deduplication. +// This helps prevent duplicate ignore records for the same vulnerability. func (v *VulnerabilityIgnore) CompositeKey() string { return v.EnvironmentID + ":" + v.ImageID + ":" + v.VulnerabilityID + ":" + v.PkgName + ":" + v.InstalledVersion } diff --git a/backend/internal/models/vulnerability_scan.go b/backend/internal/models/vulnerability_scan.go index 2f9bbb3bd2..f14a52cdf0 100644 --- a/backend/internal/models/vulnerability_scan.go +++ b/backend/internal/models/vulnerability_scan.go @@ -6,6 +6,8 @@ import ( // VulnerabilityScanRecord stores vulnerability scan results for images type VulnerabilityScanRecord struct { + BaseModel + // ImageID is the Docker image ID (primary key) ID string `json:"id" gorm:"primaryKey;type:text"` @@ -37,8 +39,6 @@ type VulnerabilityScanRecord struct { // ScannerVersion is the version of the scanner used ScannerVersion string `json:"scannerVersion" gorm:"column:scanner_version"` - - BaseModel } func (v *VulnerabilityScanRecord) TableName() string { diff --git a/backend/internal/models/webhook.go b/backend/internal/models/webhook.go index 5275dde023..fd0ad0d638 100644 --- a/backend/internal/models/webhook.go +++ b/backend/internal/models/webhook.go @@ -20,6 +20,8 @@ const ( ) type Webhook struct { + BaseModel + Name string `json:"name" gorm:"column:name;not null"` TokenHash string `json:"-" gorm:"column:token_hash;not null;uniqueIndex"` TokenPrefix string `json:"tokenPrefix" gorm:"column:token_prefix;not null"` @@ -30,7 +32,6 @@ type Webhook struct { EnvironmentID string `json:"environmentId" gorm:"column:environment_id;not null;default:''"` Enabled bool `json:"enabled" gorm:"column:enabled;not null;default:true"` LastTriggeredAt *time.Time `json:"lastTriggeredAt,omitempty" gorm:"column:last_triggered_at"` - BaseModel } func (Webhook) TableName() string { diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go new file mode 100644 index 0000000000..2a826bd570 --- /dev/null +++ b/backend/internal/services/activity_service.go @@ -0,0 +1,873 @@ +package services + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "slices" + "strings" + "sync" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" + activitytypes "github.com/getarcaneapp/arcane/types/activity" + "gorm.io/gorm" +) + +const ( + defaultActivityRetentionDays = 30 + defaultActivityHistoryLimit = 1000 + defaultActivityMessages = 500 +) + +type ActivityService struct { + db *database.DB + + subscribersMu sync.RWMutex + subscribers map[int]*activitySubscriber + nextSubID int + + // running maps an active activity ID to the cancel function of its work + // context, so cancellation requests can interrupt in-flight work. Entries + // are added by Track and removed when the activity is completed. + runningMu sync.Mutex + running map[string]context.CancelCauseFunc +} + +// ErrActivityNotCancelable indicates the activity has already reached a terminal +// state and can no longer be cancelled. +var ErrActivityNotCancelable = errors.New("activity is not cancelable") + +type activitySubscriber struct { + environmentID string + ch chan activitytypes.StreamEvent + missedEvents bool +} + +type StartActivityRequest = activitylib.StartRequest +type UpdateActivityRequest = activitylib.UpdateRequest +type AppendActivityMessageRequest = activitylib.AppendMessageRequest + +func NewActivityService(db *database.DB) *ActivityService { + return &ActivityService{ + db: db, + subscribers: map[int]*activitySubscriber{}, + running: map[string]context.CancelCauseFunc{}, + } +} + +// Track derives a cancelable work context bound to activityID and registers its +// cancel function so RequestCancel can interrupt the work. The registration is +// released when the activity is completed (see CompleteActivity) or when the +// returned context is otherwise no longer needed. Implements activitylib.Tracker. +func (s *ActivityService) Track(ctx context.Context, activityID string) context.Context { + activityID = strings.TrimSpace(activityID) + if s == nil || activityID == "" { + return ctx + } + + workCtx, cancel := context.WithCancelCause(ctx) + s.runningMu.Lock() + if s.running == nil { + s.running = map[string]context.CancelCauseFunc{} + } + if existing, ok := s.running[activityID]; ok { + // Replace any stale registration to avoid leaking the prior context. + existing(nil) + } + s.running[activityID] = cancel + s.runningMu.Unlock() + return workCtx +} + +// RequestCancel cancels the work context registered for activityID, signalling +// activitylib.ErrCanceled as the cause. It returns whether a running activity +// was found in this process. +func (s *ActivityService) RequestCancel(activityID string) bool { + activityID = strings.TrimSpace(activityID) + if s == nil || activityID == "" { + return false + } + + s.runningMu.Lock() + cancel, ok := s.running[activityID] + s.runningMu.Unlock() + if !ok { + return false + } + cancel(activitylib.ErrCanceled) + return true +} + +// releaseCancelInternal removes and cancels the registration for activityID. +// Cancelling with a nil cause is a no-op if the context was already cancelled +// (the first cause wins), so a prior ErrCanceled cause is preserved. +func (s *ActivityService) releaseCancelInternal(activityID string) { + activityID = strings.TrimSpace(activityID) + if s == nil || activityID == "" { + return + } + + s.runningMu.Lock() + cancel, ok := s.running[activityID] + if ok { + delete(s.running, activityID) + } + s.runningMu.Unlock() + if ok { + cancel(nil) + } +} + +func (s *ActivityService) checkInitInternal() error { + if s == nil || s.db == nil { + return errors.New("activity service not initialized") + } + return nil +} + +func (s *ActivityService) StartActivity(ctx context.Context, req StartActivityRequest) (*activitytypes.Activity, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + + now := time.Now() + environmentID := strings.TrimSpace(req.EnvironmentID) + if environmentID == "" { + environmentID = "0" + } + + var startedByUserID, startedByUsername, startedByDisplayName *string + if req.StartedBy != nil { + startedByUserID = utils.StringPtrFromTrimmed(req.StartedBy.ID) + startedByUsername = utils.StringPtrFromTrimmed(req.StartedBy.Username) + if req.StartedBy.DisplayName != nil { + startedByDisplayName = utils.StringPtrFromTrimmed(*req.StartedBy.DisplayName) + } + } + + model := &models.Activity{ + EnvironmentID: environmentID, + Type: req.Type, + Status: models.ActivityStatusRunning, + ResourceType: copyPtrInternal(req.ResourceType), + ResourceID: copyPtrInternal(req.ResourceID), + ResourceName: copyPtrInternal(req.ResourceName), + StartedByUserID: startedByUserID, + StartedByUsername: startedByUsername, + StartedByDisplayName: startedByDisplayName, + Progress: clampProgressPtrInternal(req.Progress), + Step: strings.TrimSpace(req.Step), + LatestMessage: strings.TrimSpace(req.LatestMessage), + StartedAt: now, + Metadata: cloneJSONInternal(req.Metadata), + BaseModel: models.BaseModel{ + CreatedAt: now, + }, + } + if model.Type == "" { + model.Type = models.ActivityTypeAutoUpdate + } + + if err := s.db.WithContext(ctx).Create(model).Error; err != nil { + return nil, fmt.Errorf("failed to create activity: %w", err) + } + + dto := activityToDTOInternal(model) + s.publishActivityInternal(dto) + return &dto, nil +} + +func (s *ActivityService) UpdateActivity(ctx context.Context, activityID string, req UpdateActivityRequest) (*activitytypes.Activity, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, errors.New("activity id is required") + } + + updates := map[string]any{ + "updated_at": time.Now(), + } + if req.Status != "" { + updates["status"] = req.Status + } + if req.Progress != nil { + updates["progress"] = *clampProgressPtrInternal(req.Progress) + } + if req.Step != nil { + updates["step"] = strings.TrimSpace(*req.Step) + } + if req.LatestMessage != nil { + updates["latest_message"] = strings.TrimSpace(*req.LatestMessage) + } + if req.Error != nil { + updates["error"] = strings.TrimSpace(*req.Error) + } + if req.Metadata != nil { + updates["metadata"] = cloneJSONInternal(req.Metadata) + } + + var model models.Activity + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&models.Activity{}).Where("id = ?", activityID).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update activity: %w", result.Error) + } + if result.RowsAffected == 0 { + return errors.New("activity not found") + } + if err := tx.First(&model, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load updated activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + dto := activityToDTOInternal(&model) + s.publishActivityInternal(dto) + return &dto, nil +} + +func (s *ActivityService) AppendMessage(ctx context.Context, activityID string, req AppendActivityMessageRequest) (*activitytypes.Message, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, errors.New("activity id is required") + } + + messageText := strings.TrimSpace(req.Message) + if messageText == "" { + return nil, nil + } + if len(messageText) > 8192 { + messageText = messageText[:8192] + } + + level := req.Level + if level == "" { + level = models.ActivityMessageLevelInfo + } + + now := time.Now() + message := &models.ActivityMessage{ + ActivityID: activityID, + Level: level, + Message: messageText, + Payload: cloneJSONInternal(req.Payload), + BaseModel: models.BaseModel{ + CreatedAt: now, + }, + } + + var updated models.Activity + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(message).Error; err != nil { + return fmt.Errorf("failed to append activity message: %w", err) + } + + updates := map[string]any{ + "latest_message": messageText, + "updated_at": now, + } + if req.Progress != nil { + updates["progress"] = *clampProgressPtrInternal(req.Progress) + } + if strings.TrimSpace(req.Step) != "" { + updates["step"] = strings.TrimSpace(req.Step) + } + + result := tx.Model(&models.Activity{}).Where("id = ?", activityID).Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to update activity latest message: %w", result.Error) + } + if result.RowsAffected == 0 { + return errors.New("activity not found") + } + if err := tx.First(&updated, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load updated activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + dto := activityMessageToDTOInternal(message) + s.publishMessageInternal(updated.EnvironmentID, dto) + s.publishActivityInternal(activityToDTOInternal(&updated)) + return &dto, nil +} + +func (s *ActivityService) CompleteActivity(ctx context.Context, activityID string, status models.ActivityStatus, finalMessage string, errMessage *string, finalStep ...string) (*activitytypes.Activity, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + if status == "" { + status = models.ActivityStatusSuccess + } + if status != models.ActivityStatusSuccess && status != models.ActivityStatusFailed && status != models.ActivityStatusCancelled { + status = models.ActivityStatusSuccess + } + + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, errors.New("activity id is required") + } + + // The activity is reaching a terminal state; release any cancel registration. + s.releaseCancelInternal(activityID) + + // Detach from cancellation so the terminal write always lands — completion is + // often triggered precisely because the work context was cancelled. + ctx = context.WithoutCancel(ctx) + + now := time.Now() + var model models.Activity + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&model, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load activity: %w", err) + } + + updates := completeActivityUpdatesInternal(model.StartedAt, status, finalMessage, errMessage, finalStep, now) + if err := tx.Model(&models.Activity{}).Where("id = ?", activityID).Updates(updates).Error; err != nil { + return fmt.Errorf("failed to complete activity: %w", err) + } + if err := tx.First(&model, "id = ?", activityID).Error; err != nil { + return fmt.Errorf("failed to load completed activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + if strings.TrimSpace(finalMessage) != "" { + level := models.ActivityMessageLevelSuccess + switch status { + case models.ActivityStatusFailed: + level = models.ActivityMessageLevelError + case models.ActivityStatusCancelled: + level = models.ActivityMessageLevelWarning + case models.ActivityStatusQueued, models.ActivityStatusRunning, models.ActivityStatusSuccess: + } + activityCtx := utils.ActivityRuntimeContext(ctx, nil) + if _, err := s.AppendMessage(activityCtx, activityID, AppendActivityMessageRequest{ + Level: level, + Message: finalMessage, + }); err != nil { + slog.DebugContext(ctx, "failed to append final activity message", "activityId", activityID, "error", err) + } + if err := s.db.WithContext(activityCtx).First(&model, "id = ?", activityID).Error; err != nil { + slog.DebugContext(ctx, "failed to reload activity after appending message", "activityId", activityID, "error", err) + } + } + + dto := activityToDTOInternal(&model) + s.publishActivityInternal(dto) + return &dto, nil +} + +// CancelActivity requests cancellation of a running or queued activity. When the +// activity's work is running in this process it interrupts it (the work finalizes +// its own terminal status); otherwise it marks the activity cancelled directly, +// but only if it is still active. Returns ErrActivityNotCancelable if the activity +// has already reached a terminal state, or gorm.ErrRecordNotFound if it is unknown. +func (s *ActivityService) CancelActivity(ctx context.Context, environmentID, activityID, requestedBy string) (*activitytypes.Activity, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + activityID = strings.TrimSpace(activityID) + if activityID == "" { + return nil, errors.New("activity id is required") + } + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + var model models.Activity + if err := s.db.WithContext(ctx).Where("id = ? AND environment_id = ?", activityID, environmentID).First(&model).Error; err != nil { + return nil, err + } + switch model.Status { + case models.ActivityStatusSuccess, models.ActivityStatusFailed, models.ActivityStatusCancelled: + return nil, ErrActivityNotCancelable + case models.ActivityStatusQueued, models.ActivityStatusRunning: + // Active states — cancellation can proceed. + } + + requestedBy = strings.TrimSpace(requestedBy) + if requestedBy == "" { + requestedBy = "a user" + } + writeCtx := utils.ActivityRuntimeContext(ctx, nil) + if _, err := s.AppendMessage(writeCtx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelWarning, + Message: "Cancellation requested by " + requestedBy, + }); err != nil { + slog.DebugContext(ctx, "failed to append cancellation message", "activityId", activityID, "error", err) + } + + if s.RequestCancel(activityID) { + // The running work observes the cancelled context and writes its own + // terminal status, which reaches clients via the activity stream. Return + // the pre-cancel snapshot rather than reloading here: the worker has not + // finished unwinding yet, so a reload would still report "running". + return new(activityToDTOInternal(&model)), nil + } + + // Untracked work (e.g. after a process restart, or a queued activity with no + // runner): finalize directly, but only if it is still active to avoid + // clobbering a concurrently-completing activity. + now := time.Now() + var finalized models.Activity + if err := s.db.WithContext(writeCtx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&finalized, "id = ? AND environment_id = ?", activityID, environmentID).Error; err != nil { + return err + } + updates := completeActivityUpdatesInternal(finalized.StartedAt, models.ActivityStatusCancelled, cancelledMessageInternal, nil, nil, now) + result := tx.Model(&models.Activity{}). + Where("id = ? AND status IN ?", activityID, []models.ActivityStatus{models.ActivityStatusQueued, models.ActivityStatusRunning}). + Updates(updates) + if result.Error != nil { + return fmt.Errorf("failed to cancel activity: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrActivityNotCancelable + } + if err := tx.First(&finalized, "id = ? AND environment_id = ?", activityID, environmentID).Error; err != nil { + return fmt.Errorf("failed to load cancelled activity: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + dto := activityToDTOInternal(&finalized) + s.publishActivityInternal(dto) + return &dto, nil +} + +const cancelledMessageInternal = "Cancelled by user" + +func completeActivityUpdatesInternal(startedAt time.Time, status models.ActivityStatus, finalMessage string, errMessage *string, finalStep []string, now time.Time) map[string]any { + updates := map[string]any{ + "status": status, + "ended_at": now, + "duration_ms": now.Sub(startedAt).Milliseconds(), + "updated_at": now, + } + if trimmed := strings.TrimSpace(finalMessage); trimmed != "" { + updates["latest_message"] = trimmed + } + if len(finalStep) > 0 { + if step := strings.TrimSpace(finalStep[0]); step != "" { + updates["step"] = step + } + } + if errMessage != nil && strings.TrimSpace(*errMessage) != "" { + updates["error"] = strings.TrimSpace(*errMessage) + } + if status == models.ActivityStatusSuccess { + updates["progress"] = 100 + } + return updates +} + +func (s *ActivityService) ListActivitiesPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]activitytypes.Activity, pagination.Response, error) { + if err := s.checkInitInternal(); err != nil { + return nil, pagination.Response{}, err + } + + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + var activities []models.Activity + q := s.db.WithContext(ctx).Model(&models.Activity{}).Where("environment_id = ?", environmentID) + + if term := strings.TrimSpace(params.Search); term != "" { + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(term) + searchPattern := "%" + escaped + "%" + q = q.Where( + "type LIKE ? ESCAPE '\\' OR COALESCE(resource_name, '') LIKE ? ESCAPE '\\' OR COALESCE(latest_message, '') LIKE ? ESCAPE '\\' OR COALESCE(step, '') LIKE ? ESCAPE '\\' OR COALESCE(error, '') LIKE ? ESCAPE '\\'", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + q = pagination.ApplyFilter(q, "status", params.Filters["status"]) + q = pagination.ApplyFilter(q, "type", params.Filters["type"]) + q = pagination.ApplyFilter(q, "resource_type", params.Filters["resourceType"]) + + if params.Sort == "" { + q = q.Order("CASE WHEN status IN ('queued', 'running') THEN 0 ELSE 1 END ASC"). + Order("COALESCE(updated_at, created_at) DESC"). + Order("started_at DESC") + } + + paginationResp, err := pagination.PaginateAndSortDB(params, q, &activities) + if err != nil { + return nil, pagination.Response{}, fmt.Errorf("failed to paginate activities: %w", err) + } + + out := make([]activitytypes.Activity, 0, len(activities)) + for i := range activities { + out = append(out, activityToDTOInternal(&activities[i])) + } + return out, paginationResp, nil +} + +func (s *ActivityService) GetActivityDetail(ctx context.Context, environmentID, activityID string, limit int) (*activitytypes.Detail, error) { + if err := s.checkInitInternal(); err != nil { + return nil, err + } + if limit <= 0 || limit > defaultActivityMessages { + limit = defaultActivityMessages + } + + var model models.Activity + if err := s.db.WithContext(ctx). + Where("id = ? AND environment_id = ?", activityID, environmentID). + First(&model).Error; err != nil { + return nil, fmt.Errorf("failed to load activity: %w", err) + } + + var messages []models.ActivityMessage + if err := s.db.WithContext(ctx). + Where("activity_id = ?", activityID). + Order("created_at DESC"). + Limit(limit). + Find(&messages).Error; err != nil { + return nil, fmt.Errorf("failed to load activity messages: %w", err) + } + + outMessages := make([]activitytypes.Message, 0, len(messages)) + for _, v := range slices.Backward(messages) { + outMessages = append(outMessages, activityMessageToDTOInternal(&v)) + } + + return &activitytypes.Detail{ + Activity: activityToDTOInternal(&model), + Messages: outMessages, + }, nil +} + +func (s *ActivityService) PruneHistory(ctx context.Context, retentionDays, maxEntries int) (int64, error) { + if s == nil || s.db == nil { + return 0, nil + } + if retentionDays < 0 { + retentionDays = defaultActivityRetentionDays + } + if maxEntries < 0 { + maxEntries = defaultActivityHistoryLimit + } + + var deleted int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if retentionDays > 0 { + cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour) + ids, err := findTerminalActivityIDsInternal(tx. + Where("COALESCE(ended_at, updated_at, created_at) < ?", cutoff)) + if err != nil { + return fmt.Errorf("failed to find activities older than retention window: %w", err) + } + count, err := deleteActivitiesByIDInternal(tx, ids) + if err != nil { + return err + } + deleted += count + } + + if maxEntries > 0 { + ids, err := findActivityIDsBeyondHistoryLimitInternal(tx, maxEntries) + if err != nil { + return err + } + count, err := deleteActivitiesByIDInternal(tx, ids) + if err != nil { + return err + } + deleted += count + } + + return nil + }); err != nil { + return 0, err + } + + return deleted, nil +} + +func (s *ActivityService) DeleteHistory(ctx context.Context, environmentID string) (int64, error) { + if s == nil || s.db == nil { + return 0, nil + } + + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + var deleted int64 + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ids, err := findTerminalActivityIDsInternal(tx.Where("environment_id = ?", environmentID)) + if err != nil { + return fmt.Errorf("failed to find activity history: %w", err) + } + count, err := deleteActivitiesByIDInternal(tx, ids) + if err != nil { + return err + } + deleted = count + return nil + }); err != nil { + return 0, err + } + + return deleted, nil +} + +func (s *ActivityService) Subscribe(environmentID string) (<-chan activitytypes.StreamEvent, func() bool, func()) { + ch := make(chan activitytypes.StreamEvent, 64) + if s == nil { + close(ch) + return ch, func() bool { return false }, func() {} + } + + environmentID = strings.TrimSpace(environmentID) + if environmentID == "" { + environmentID = "0" + } + + s.subscribersMu.Lock() + s.nextSubID++ + id := s.nextSubID + s.subscribers[id] = &activitySubscriber{environmentID: environmentID, ch: ch} + s.subscribersMu.Unlock() + + missedEvents := func() bool { + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + + sub, ok := s.subscribers[id] + if !ok || !sub.missedEvents { + return false + } + sub.missedEvents = false + return true + } + + unsubscribe := func() { + s.subscribersMu.Lock() + if sub, ok := s.subscribers[id]; ok { + delete(s.subscribers, id) + close(sub.ch) + } + s.subscribersMu.Unlock() + } + + return ch, missedEvents, unsubscribe +} + +func (s *ActivityService) publishActivityInternal(activity activitytypes.Activity) { + s.publishInternal(activity.EnvironmentID, activitytypes.StreamEvent{ + Type: "activity", + ActivityID: activity.ID, + Activity: &activity, + Timestamp: time.Now(), + }) +} + +func (s *ActivityService) publishMessageInternal(environmentID string, message activitytypes.Message) { + s.publishInternal(environmentID, activitytypes.StreamEvent{ + Type: "message", + ActivityID: message.ActivityID, + Message: &message, + Timestamp: time.Now(), + }) +} + +func (s *ActivityService) publishInternal(environmentID string, event activitytypes.StreamEvent) { + if s == nil { + return + } + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + + for _, sub := range s.subscribers { + if sub.environmentID != environmentID { + continue + } + select { + case sub.ch <- event: + default: + sub.missedEvents = true + slog.Warn("activity subscriber event buffer full; snapshot will be sent on next heartbeat", "environmentId", environmentID, "eventType", event.Type) + } + } +} + +func activityToDTOInternal(model *models.Activity) activitytypes.Activity { + if model == nil { + return activitytypes.Activity{} + } + return activitytypes.Activity{ + ID: model.ID, + EnvironmentID: model.EnvironmentID, + SourceEnvironmentID: model.EnvironmentID, + Type: activitytypes.Type(model.Type), + Status: activitytypes.Status(model.Status), + ResourceType: copyPtrInternal(model.ResourceType), + ResourceID: copyPtrInternal(model.ResourceID), + ResourceName: copyPtrInternal(model.ResourceName), + Progress: clampProgressPtrInternal(model.Progress), + Step: model.Step, + LatestMessage: model.LatestMessage, + StartedBy: activityStartedByDTOInternal(model), + StartedAt: model.StartedAt, + EndedAt: copyPtrInternal(model.EndedAt), + DurationMs: copyPtrInternal(model.DurationMs), + Error: copyPtrInternal(model.Error), + Metadata: jsonToMapInternal(model.Metadata), + CreatedAt: model.CreatedAt, + UpdatedAt: copyPtrInternal(model.UpdatedAt), + } +} + +func activityMessageToDTOInternal(model *models.ActivityMessage) activitytypes.Message { + if model == nil { + return activitytypes.Message{} + } + return activitytypes.Message{ + ID: model.ID, + ActivityID: model.ActivityID, + Level: activitytypes.MessageLevel(model.Level), + Message: model.Message, + Payload: jsonToMapInternal(model.Payload), + CreatedAt: model.CreatedAt, + } +} + +func copyPtrInternal[T any](value *T) *T { + if value == nil { + return nil + } + return new(*value) +} + +func clampProgressPtrInternal(value *int) *int { + if value == nil { + return nil + } + clamped := min(max(*value, 0), 100) + return &clamped +} + +func cloneJSONInternal(input models.JSON) models.JSON { + if len(input) == 0 { + return nil + } + out := make(models.JSON, len(input)) + maps.Copy(out, input) + return out +} + +func jsonToMapInternal(input models.JSON) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + maps.Copy(out, input) + return out +} + +func terminalActivityStatusesInternal() []models.ActivityStatus { + return []models.ActivityStatus{ + models.ActivityStatusSuccess, + models.ActivityStatusFailed, + models.ActivityStatusCancelled, + } +} + +func findTerminalActivityIDsInternal(q *gorm.DB) ([]string, error) { + var activityIDs []string + if err := q.Model(&models.Activity{}). + Where("status IN ?", terminalActivityStatusesInternal()). + Pluck("id", &activityIDs).Error; err != nil { + return nil, err + } + return activityIDs, nil +} + +func findActivityIDsBeyondHistoryLimitInternal(tx *gorm.DB, maxEntries int) ([]string, error) { + var activityIDs []string + if err := tx.Raw(` + SELECT ranked.id + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY environment_id + ORDER BY COALESCE(ended_at, updated_at, created_at) DESC, started_at DESC + ) AS activity_rank + FROM activities + WHERE status IN ? + ) ranked + WHERE ranked.activity_rank > ? + `, terminalActivityStatusesInternal(), maxEntries).Scan(&activityIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find excess activities: %w", err) + } + return activityIDs, nil +} + +const deleteActivitiesBatchSize = 500 + +func deleteActivitiesByIDInternal(tx *gorm.DB, activityIDs []string) (int64, error) { + if len(activityIDs) == 0 { + return 0, nil + } + + var totalDeleted int64 + for i := 0; i < len(activityIDs); i += deleteActivitiesBatchSize { + end := min(i+deleteActivitiesBatchSize, len(activityIDs)) + batch := activityIDs[i:end] + + if err := tx.Where("activity_id IN ?", batch).Delete(&models.ActivityMessage{}).Error; err != nil { + return totalDeleted, fmt.Errorf("failed to delete activity messages: %w", err) + } + result := tx.Where("id IN ?", batch).Delete(&models.Activity{}) + if result.Error != nil { + return totalDeleted, fmt.Errorf("failed to delete activities: %w", result.Error) + } + totalDeleted += result.RowsAffected + } + + return totalDeleted, nil +} + +func activityStartedByDTOInternal(model *models.Activity) *activitytypes.StartedBy { + if model.StartedByUsername == nil || strings.TrimSpace(*model.StartedByUsername) == "" { + return &activitytypes.StartedBy{Username: "System"} + } + + startedBy := &activitytypes.StartedBy{ + Username: strings.TrimSpace(*model.StartedByUsername), + } + if model.StartedByUserID != nil { + startedBy.UserID = strings.TrimSpace(*model.StartedByUserID) + } + if model.StartedByDisplayName != nil { + startedBy.DisplayName = strings.TrimSpace(*model.StartedByDisplayName) + } + return startedBy +} diff --git a/backend/internal/services/activity_service_test.go b/backend/internal/services/activity_service_test.go new file mode 100644 index 0000000000..6419fbb5b9 --- /dev/null +++ b/backend/internal/services/activity_service_test.go @@ -0,0 +1,362 @@ +package services + +import ( + "context" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" + activitytypes "github.com/getarcaneapp/arcane/types/activity" +) + +func setupActivityServiceTestDBInternal(t *testing.T) *database.DB { + t.Helper() + + db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Activity{}, &models.ActivityMessage{})) + return &database.DB{DB: db} +} + +func TestActivityServiceLifecycleInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + progress := 5 + startedBy := &models.User{ + BaseModel: models.BaseModel{ID: "user-1"}, + Username: "arcane", + DisplayName: new("Arcane Admin"), + } + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeImagePull, + ResourceType: new("image"), + ResourceID: new("img-123"), + ResourceName: new("nginx:latest"), + StartedBy: startedBy, + Progress: &progress, + Step: "queued", + LatestMessage: "Pull queued", + }) + require.NoError(t, err) + require.NotEmpty(t, created.ID) + require.Equal(t, "0", created.EnvironmentID) + require.Equal(t, "running", string(created.Status)) + require.Equal(t, 5, *created.Progress) + require.NotNil(t, created.StartedBy) + require.Equal(t, "user-1", created.StartedBy.UserID) + require.Equal(t, "arcane", created.StartedBy.Username) + require.Equal(t, "Arcane Admin", created.StartedBy.DisplayName) + + progress = 42 + message, err := service.AppendMessage(ctx, created.ID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: "Downloading layers", + Progress: &progress, + Step: "download", + }) + require.NoError(t, err) + require.NotNil(t, message) + require.Equal(t, created.ID, message.ActivityID) + + completed, err := service.CompleteActivity(ctx, created.ID, models.ActivityStatusSuccess, "Pull complete", nil) + require.NoError(t, err) + require.Equal(t, "success", string(completed.Status)) + require.NotNil(t, completed.EndedAt) + require.NotNil(t, completed.DurationMs) + require.Equal(t, 100, *completed.Progress) + + list, paginationResp, err := service.ListActivitiesPaginated(ctx, "0", pagination.QueryParams{ + Params: pagination.Params{Limit: 10}, + }) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, int64(1), paginationResp.TotalItems) + require.Equal(t, created.ID, list[0].ID) + + detail, err := service.GetActivityDetail(ctx, "0", created.ID, 10) + require.NoError(t, err) + require.Equal(t, created.ID, detail.Activity.ID) + require.Len(t, detail.Messages, 2) + require.Equal(t, "Downloading layers", detail.Messages[0].Message) + require.Equal(t, "Pull complete", detail.Messages[1].Message) +} + +func TestActivityServiceStreamFanoutInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + events, _, unsubscribe := service.Subscribe("0") + defer unsubscribe() + + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeProjectDeploy, + LatestMessage: "Deploy queued", + }) + require.NoError(t, err) + + first := receiveActivityEventInternal(t, events) + require.Equal(t, "activity", first.Type) + require.Equal(t, created.ID, first.ActivityID) + require.NotNil(t, first.Activity) + + _, err = service.AppendMessage(ctx, created.ID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: "Deploying services", + Step: "deploy", + }) + require.NoError(t, err) + + messageEvent := receiveActivityEventInternal(t, events) + require.Equal(t, "message", messageEvent.Type) + require.Equal(t, created.ID, messageEvent.ActivityID) + require.NotNil(t, messageEvent.Message) + require.Equal(t, "Deploying services", messageEvent.Message.Message) +} + +func TestActivityServiceRetentionCleanupInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeSystemPrune, + LatestMessage: "Prune started", + }) + require.NoError(t, err) + _, err = service.AppendMessage(ctx, created.ID, AppendActivityMessageRequest{ + Message: "Removing unused resources", + }) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, created.ID, models.ActivityStatusSuccess, "Prune complete", nil) + require.NoError(t, err) + + oldEndedAt := time.Now().Add(-((time.Duration(defaultActivityRetentionDays) * 24 * time.Hour) + time.Hour)) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", created.ID).Update("ended_at", oldEndedAt).Error) + + deleted, err := service.PruneHistory(ctx, defaultActivityRetentionDays, 0) + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + + var activityCount int64 + require.NoError(t, db.Model(&models.Activity{}).Count(&activityCount).Error) + require.Zero(t, activityCount) + + var messageCount int64 + require.NoError(t, db.Model(&models.ActivityMessage{}).Count(&messageCount).Error) + require.Zero(t, messageCount) +} + +func TestActivityServicePruneHistoryZeroRetentionDisablesAgeCleanupInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeSystemPrune, + LatestMessage: "Prune started", + }) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, created.ID, models.ActivityStatusSuccess, "Prune complete", nil) + require.NoError(t, err) + + oldEndedAt := time.Now().Add(-((time.Duration(defaultActivityRetentionDays) * 24 * time.Hour) + time.Hour)) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", created.ID).Update("ended_at", oldEndedAt).Error) + + deleted, err := service.PruneHistory(ctx, 0, 0) + require.NoError(t, err) + require.Zero(t, deleted) + + var activityCount int64 + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", created.ID).Count(&activityCount).Error) + require.EqualValues(t, 1, activityCount) +} + +func TestActivityServiceSubscribeMarksMissedEventsWhenBufferFullInternal(t *testing.T) { + service := NewActivityService(nil) + + events, missedEvents, unsubscribe := service.Subscribe("0") + defer unsubscribe() + + for i := 0; i < cap(events); i++ { + service.publishInternal("0", activitytypes.StreamEvent{Type: "activity"}) + } + require.False(t, missedEvents()) + + service.publishInternal("0", activitytypes.StreamEvent{Type: "activity"}) + require.True(t, missedEvents()) + require.False(t, missedEvents()) +} + +func TestActivityServiceDeleteHistoryPreservesActiveActivitiesInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + completed, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = service.AppendMessage(ctx, completed.ID, AppendActivityMessageRequest{Message: "done"}) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, completed.ID, models.ActivityStatusSuccess, "complete", nil) + require.NoError(t, err) + + running, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + + remoteCompleted, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, remoteCompleted.ID, models.ActivityStatusFailed, "failed", nil) + require.NoError(t, err) + + deleted, err := service.DeleteHistory(ctx, "0") + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + + var remaining []models.Activity + require.NoError(t, db.Order("id").Find(&remaining).Error) + require.Len(t, remaining, 2) + require.ElementsMatch(t, []string{running.ID, remoteCompleted.ID}, []string{remaining[0].ID, remaining[1].ID}) +} + +func TestActivityServicePruneHistoryByAgeAndCountInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + oldActivity, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "0", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + _, err = service.CompleteActivity(ctx, oldActivity.ID, models.ActivityStatusSuccess, "old", nil) + require.NoError(t, err) + oldTime := time.Now().Add(-48 * time.Hour) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", oldActivity.ID).Updates(map[string]any{ + "ended_at": oldTime, + "updated_at": oldTime, + }).Error) + + for i := 0; i < 3; i++ { + item, startErr := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, startErr) + _, completeErr := service.CompleteActivity(ctx, item.ID, models.ActivityStatusSuccess, "done", nil) + require.NoError(t, completeErr) + stamp := time.Now().Add(time.Duration(i) * time.Minute) + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", item.ID).Updates(map[string]any{ + "ended_at": stamp, + "updated_at": stamp, + }).Error) + } + + running, err := service.StartActivity(ctx, StartActivityRequest{EnvironmentID: "remote-1", Type: models.ActivityTypeResourceAction}) + require.NoError(t, err) + + deleted, err := service.PruneHistory(ctx, 1, 2) + require.NoError(t, err) + require.EqualValues(t, 2, deleted) + + var terminalRemoteCount int64 + require.NoError(t, db.Model(&models.Activity{}). + Where("environment_id = ? AND status IN ?", "remote-1", terminalActivityStatusesInternal()). + Count(&terminalRemoteCount).Error) + require.EqualValues(t, 2, terminalRemoteCount) + + var runningCount int64 + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", running.ID).Count(&runningCount).Error) + require.EqualValues(t, 1, runningCount) + + var oldCount int64 + require.NoError(t, db.Model(&models.Activity{}).Where("id = ?", oldActivity.ID).Count(&oldCount).Error) + require.Zero(t, oldCount) +} + +func TestActivityServiceCompleteActivityRejectsUninitializedServiceInternal(t *testing.T) { + service := NewActivityService(nil) + _, err := service.CompleteActivity(context.Background(), "any-id", models.ActivityStatusSuccess, "done", nil) + require.Error(t, err) +} + +func TestActivityServiceTrackAndRequestCancelInternal(t *testing.T) { + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + // Mirror the handler flow: work runs under an app-lifecycle runtime context. + appCtx := utils.WithAppLifecycleContext(context.Background()) + runtimeCtx := utils.ActivityRuntimeContext(context.Background(), appCtx) + + created, err := service.StartActivity(runtimeCtx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeImagePull, + LatestMessage: "running", + }) + require.NoError(t, err) + + workCtx := service.Track(runtimeCtx, created.ID) + require.NoError(t, workCtx.Err()) + + // A tracked activity is found and cancelled with the ErrCanceled cause. + require.True(t, service.RequestCancel(created.ID)) + require.ErrorIs(t, workCtx.Err(), context.Canceled) + require.ErrorIs(t, context.Cause(workCtx), activitylib.ErrCanceled) + require.True(t, activitylib.CancelledByContext(workCtx)) + + // Completion must land even though the work context is cancelled (this is the + // path CompleteHandlerActivity takes after re-wrapping the work context). + completed, err := service.CompleteActivity(utils.ActivityRuntimeContext(workCtx, nil), created.ID, models.ActivityStatusCancelled, "Cancelled by user", nil) + require.NoError(t, err) + require.Equal(t, "cancelled", string(completed.Status)) + require.NotNil(t, completed.EndedAt) + + // Completing the activity releases the registration. + require.False(t, service.RequestCancel(created.ID)) +} + +func TestActivityServiceCancelActivityInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + service := NewActivityService(db) + + // An untracked running activity (e.g. after a restart) is finalized directly. + created, err := service.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeSystemPrune, + LatestMessage: "running", + }) + require.NoError(t, err) + + cancelled, err := service.CancelActivity(ctx, "0", created.ID, "Tester") + require.NoError(t, err) + require.Equal(t, "cancelled", string(cancelled.Status)) + require.NotNil(t, cancelled.EndedAt) + + // Cancelling an already-terminal activity is rejected. + _, err = service.CancelActivity(ctx, "0", created.ID, "Tester") + require.ErrorIs(t, err, ErrActivityNotCancelable) + + // Unknown activity reports not found. + _, err = service.CancelActivity(ctx, "0", "missing", "Tester") + require.ErrorIs(t, err, gorm.ErrRecordNotFound) +} + +func receiveActivityEventInternal(t *testing.T, events <-chan activitytypes.StreamEvent) activitytypes.StreamEvent { + t.Helper() + + select { + case event := <-events: + return event + case <-time.After(time.Second): + t.Fatal("timed out waiting for activity event") + return activitytypes.StreamEvent{} + } +} diff --git a/backend/internal/services/api_key_service.go b/backend/internal/services/api_key_service.go index 6f655c26a5..ca0acdb00f 100644 --- a/backend/internal/services/api_key_service.go +++ b/backend/internal/services/api_key_service.go @@ -12,7 +12,9 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" "github.com/getarcaneapp/arcane/types/apikey" "gorm.io/gorm" ) @@ -42,6 +44,7 @@ var defaultAdminAPIKeyDescription = func() *string { type ApiKeyService struct { db *database.DB userService *UserService + roleService *RoleService argon2Params *Argon2Params } @@ -53,6 +56,16 @@ func NewApiKeyService(db *database.DB, userService *UserService) *ApiKeyService } } +// WithRoleService wires the RoleService dependency. Separated from the +// constructor to break the bootstrap-ordering cycle between ApiKeyService and +// RoleService (RoleService.BackfillApiKeyPermissions needs ApiKeyService to +// exist when it runs, while permission-validated CreateApiKey needs the +// RoleService). +func (s *ApiKeyService) WithRoleService(roleService *RoleService) *ApiKeyService { + s.roleService = roleService + return s +} + func (s *ApiKeyService) generateApiKey() (string, error) { bytes := make([]byte, apiKeyLength) if _, err := rand.Read(bytes); err != nil { @@ -100,12 +113,72 @@ func (s *ApiKeyService) markApiKeyUsedAsync(ctx context.Context, keyID string) { } func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, req apikey.CreateApiKey) (*apikey.ApiKeyCreatedDto, error) { + if err := s.validateGrantsAgainstUserInternal(ctx, userID, req.Permissions); err != nil { + return nil, err + } rawKey, err := s.generateApiKey() if err != nil { return nil, err } - return s.createAPIKeyWithRawKey(ctx, &userID, rawKey, req, nil, nil) + created, err := s.createAPIKeyWithRawKey(ctx, &userID, rawKey, req, nil, nil) + if err != nil { + return nil, err + } + if s.roleService != nil { + grants := toApiKeyPermissionRowsInternal(created.ID, req.Permissions) + if err := s.roleService.SetApiKeyPermissions(ctx, created.ID, grants); err != nil { + return nil, fmt.Errorf("failed to persist api key permissions: %w", err) + } + // Re-load the just-persisted grants into the response DTO so the + // frontend doesn't see `"permissions": null` on a successful create. + created.Permissions = s.loadKeyGrantsInternal(ctx, created.ID) + } + return created, nil +} + +// ErrApiKeyPermissionEscalation is returned when a caller attempts to grant an +// API key permissions they themselves do not hold. +var ErrApiKeyPermissionEscalation = errors.New("cannot grant a permission you do not have") + +// validateGrantsAgainstUserInternal refuses requests that would grant the new key +// permissions that the requesting user doesn't hold. Sudo callers bypass this +// (they always pass). If RoleService is unavailable validation is skipped (used +// only in the boot-bootstrap path where no human caller exists). +func (s *ApiKeyService) validateGrantsAgainstUserInternal(ctx context.Context, userID string, grants []apikey.PermissionGrant) error { + if s.roleService == nil || len(grants) == 0 { + return nil + } + user, err := s.userService.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("load user for permission validation: %w", err) + } + ps, err := s.roleService.ResolvePermissions(ctx, user) + if err != nil { + return fmt.Errorf("resolve user permissions: %w", err) + } + for _, g := range grants { + envID := "" + if g.EnvironmentID != nil { + envID = *g.EnvironmentID + } + if !ps.Allows(g.Permission, envID) { + return fmt.Errorf("%w: %s (env=%q)", ErrApiKeyPermissionEscalation, g.Permission, envID) + } + } + return nil +} + +func toApiKeyPermissionRowsInternal(apiKeyID string, grants []apikey.PermissionGrant) []models.ApiKeyPermission { + out := make([]models.ApiKeyPermission, len(grants)) + for i, g := range grants { + out[i] = models.ApiKeyPermission{ + ApiKeyID: apiKeyID, + Permission: g.Permission, + EnvironmentID: g.EnvironmentID, + } + } + return out } func (s *ApiKeyService) createAPIKeyWithRawKey( @@ -152,6 +225,17 @@ func isStaticAPIKeyInternal(ak models.ApiKey) bool { return ak.ManagedBy != nil && *ak.ManagedBy == managedByAdminBootstrap } +// isEnvironmentBootstrapKeyInternal identifies the auto-generated key minted by +// CreateEnvironmentApiKey for environment pairing. Those keys have no owner +// (UserID == nil) and are scoped to a single environment. They carry full +// env-scoped permissions and must never be hand-edited or deleted via the API +// — manually clearing the grants would silently break the paired edge agent +// the next time it tries to authenticate. Cascade-delete still applies when +// the environment row itself is removed. +func isEnvironmentBootstrapKeyInternal(ak models.ApiKey) bool { + return ak.UserID == nil && ak.EnvironmentID != nil +} + func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey { return apikey.ApiKey{ ID: ak.ID, @@ -160,6 +244,7 @@ func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey { KeyPrefix: ak.KeyPrefix, UserID: ak.UserID, IsStatic: isStaticAPIKeyInternal(*ak), + IsBootstrap: isEnvironmentBootstrapKeyInternal(*ak), ExpiresAt: ak.ExpiresAt, LastUsedAt: ak.LastUsedAt, CreatedAt: ak.CreatedAt, @@ -167,6 +252,14 @@ func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey { } } +// toAPIKeyDTOWithPermissionsInternal is like toAPIKeyDTOInternal but +// additionally attaches the key's persisted permission grants. +func (s *ApiKeyService) toAPIKeyDTOWithPermissionsInternal(ctx context.Context, ak *models.ApiKey) apikey.ApiKey { + dto := toAPIKeyDTOInternal(ak) + dto.Permissions = s.loadKeyGrantsInternal(ctx, ak.ID) + return dto +} + func (s *ApiKeyService) CreateDefaultAdminAPIKey(ctx context.Context, userID, rawKey string) (*apikey.ApiKeyCreatedDto, error) { return s.createAPIKeyWithRawKey(ctx, &userID, rawKey, apikey.CreateApiKey{ Name: defaultAdminAPIKeyName, @@ -315,11 +408,37 @@ func (s *ApiKeyService) CreateEnvironmentApiKey(ctx context.Context, environment if len(environmentID) > 8 { envIDShort = environmentID[:8] } - name := fmt.Sprintf("Environment Bootstrap Key - %s", envIDShort) - return s.createAPIKeyWithRawKey(ctx, nil, rawKey, apikey.CreateApiKey{ + name := "Environment Bootstrap Key - " + envIDShort + created, err := s.createAPIKeyWithRawKey(ctx, nil, rawKey, apikey.CreateApiKey{ Name: name, Description: new("Auto-generated key for environment pairing"), }, nil, &environmentID) + if err != nil { + return nil, err + } + // Env-bootstrap keys are infrastructure-level credentials used by the agent + // pairing flow; they must always carry every permission scoped to their + // environment. Without this seed, the per-key permission resolver returns + // an empty set on any request authenticated by this key and every + // downstream RequirePermission check fails with 403. + if s.roleService != nil { + all := authz.AllPermissions() + grants := make([]models.ApiKeyPermission, len(all)) + for i, p := range all { + grants[i] = models.ApiKeyPermission{ + ApiKeyID: created.ID, + Permission: p, + EnvironmentID: &environmentID, + } + } + if err := s.roleService.SetApiKeyPermissions(ctx, created.ID, grants); err != nil { + return nil, fmt.Errorf("failed to persist environment bootstrap key permissions: %w", err) + } + // Re-load grants into the response DTO so callers see the seeded + // permissions immediately, not null. + created.Permissions = s.loadKeyGrantsInternal(ctx, created.ID) + } + return created, nil } func (s *ApiKeyService) GetApiKey(ctx context.Context, id string) (*apikey.ApiKey, error) { @@ -330,19 +449,7 @@ func (s *ApiKeyService) GetApiKey(ctx context.Context, id string) (*apikey.ApiKe } return nil, fmt.Errorf("failed to get API key: %w", err) } - - return &apikey.ApiKey{ - ID: ak.ID, - Name: ak.Name, - Description: ak.Description, - KeyPrefix: ak.KeyPrefix, - UserID: ak.UserID, - IsStatic: isStaticAPIKeyInternal(ak), - ExpiresAt: ak.ExpiresAt, - LastUsedAt: ak.LastUsedAt, - CreatedAt: ak.CreatedAt, - UpdatedAt: ak.UpdatedAt, - }, nil + return new(s.toAPIKeyDTOWithPermissionsInternal(ctx, &ak)), nil } func (s *ApiKeyService) ListApiKeys(ctx context.Context, params pagination.QueryParams) ([]apikey.ApiKey, pagination.Response, error) { @@ -363,14 +470,38 @@ func (s *ApiKeyService) ListApiKeys(ctx context.Context, params pagination.Query } result := make([]apikey.ApiKey, len(apiKeys)) - for i, ak := range apiKeys { - result[i] = toAPIKeyDTOInternal(&ak) + for i := range apiKeys { + // Include per-key permission grants so the edit form preloads with the + // key's actual current grants. Without this, the form starts empty and + // "Save" would wipe whatever grants the DB has. + result[i] = s.toAPIKeyDTOWithPermissionsInternal(ctx, &apiKeys[i]) } return result, paginationResp, nil } -func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey.UpdateApiKey) (*apikey.ApiKey, error) { +// ListApiKeysByUser returns every non-static, non-bootstrap API key owned by +// userID. Used by the self-service personal-keys flow. +func (s *ApiKeyService) ListApiKeysByUser(ctx context.Context, userID string) ([]apikey.ApiKey, error) { + var apiKeys []models.ApiKey + if err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC"). + Find(&apiKeys).Error; err != nil { + return nil, fmt.Errorf("failed to list user api keys: %w", err) + } + + result := make([]apikey.ApiKey, 0, len(apiKeys)) + for i := range apiKeys { + if isStaticAPIKeyInternal(apiKeys[i]) || isEnvironmentBootstrapKeyInternal(apiKeys[i]) { + continue + } + result = append(result, s.toAPIKeyDTOWithPermissionsInternal(ctx, &apiKeys[i])) + } + return result, nil +} + +func (s *ApiKeyService) UpdateApiKey(ctx context.Context, callerUserID, id string, req apikey.UpdateApiKey) (*apikey.ApiKey, error) { var ak models.ApiKey if err := s.db.WithContext(ctx).Where("id = ?", id).First(&ak).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -378,10 +509,24 @@ func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey. } return nil, fmt.Errorf("failed to get API key: %w", err) } - if isStaticAPIKeyInternal(ak) { + if isStaticAPIKeyInternal(ak) || isEnvironmentBootstrapKeyInternal(ak) { return nil, ErrApiKeyProtected } + if req.Permissions != nil { + // Validate against the key owner's permissions, not the caller's, so a + // holder of apikeys:update cannot escalate another user's key beyond + // what that user's own roles allow. Owner-less keys (env bootstrap) + // fall back to the caller — static admin keys are rejected above. + ownerID := callerUserID + if ak.UserID != nil { + ownerID = *ak.UserID + } + if err := s.validateGrantsAgainstUserInternal(ctx, ownerID, req.Permissions); err != nil { + return nil, err + } + } + if req.Name != nil { ak.Name = *req.Name } @@ -392,8 +537,23 @@ func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey. ak.ExpiresAt = req.ExpiresAt } - if err := s.db.WithContext(ctx).Save(&ak).Error; err != nil { - return nil, fmt.Errorf("failed to update API key: %w", err) + permissionsUpdated := false + if err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Save(&ak).Error; err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } + if req.Permissions != nil && s.roleService != nil { + if err := s.roleService.setApiKeyPermissionsInternal(ctx, tx, ak.ID, toApiKeyPermissionRowsInternal(ak.ID, req.Permissions)); err != nil { + return fmt.Errorf("failed to update api key permissions: %w", err) + } + permissionsUpdated = true + } + return nil + }); err != nil { + return nil, err + } + if permissionsUpdated { + s.roleService.apiKeyCache.Delete(ak.ID) } return &apikey.ApiKey{ @@ -403,13 +563,37 @@ func (s *ApiKeyService) UpdateApiKey(ctx context.Context, id string, req apikey. KeyPrefix: ak.KeyPrefix, UserID: ak.UserID, IsStatic: isStaticAPIKeyInternal(ak), + IsBootstrap: isEnvironmentBootstrapKeyInternal(ak), ExpiresAt: ak.ExpiresAt, LastUsedAt: ak.LastUsedAt, CreatedAt: ak.CreatedAt, UpdatedAt: ak.UpdatedAt, + Permissions: s.loadKeyGrantsInternal(ctx, ak.ID), }, nil } +// loadKeyGrantsInternal returns the persisted permission grants for an API key. +// Always returns a non-nil slice (possibly empty) so the JSON-marshalled DTO +// renders as `"permissions": []` rather than `"permissions": null`, which the +// frontend treats differently (null hides the picker, [] shows it empty). +// DB errors are logged and yield an empty slice — the caller never sees nil. +func (s *ApiKeyService) loadKeyGrantsInternal(ctx context.Context, apiKeyID string) []apikey.PermissionGrant { + empty := []apikey.PermissionGrant{} + if s.roleService == nil { + return empty + } + var rows []models.ApiKeyPermission + if err := s.db.WithContext(ctx).Where("api_key_id = ?", apiKeyID).Find(&rows).Error; err != nil { + slog.WarnContext(ctx, "failed to load api key permission grants", "api_key_id", apiKeyID, "error", err) + return empty + } + out := make([]apikey.PermissionGrant, len(rows)) + for i, r := range rows { + out[i] = apikey.PermissionGrant{Permission: r.Permission, EnvironmentID: r.EnvironmentID} + } + return out +} + func (s *ApiKeyService) DeleteApiKey(ctx context.Context, id string) error { var apiKey models.ApiKey if err := s.db.WithContext(ctx).Where("id = ?", id).First(&apiKey).Error; err != nil { @@ -418,7 +602,7 @@ func (s *ApiKeyService) DeleteApiKey(ctx context.Context, id string) error { } return fmt.Errorf("failed to load API key: %w", err) } - if isStaticAPIKeyInternal(apiKey) { + if isStaticAPIKeyInternal(apiKey) || isEnvironmentBootstrapKeyInternal(apiKey) { return ErrApiKeyProtected } @@ -433,39 +617,46 @@ func (s *ApiKeyService) DeleteApiKey(ctx context.Context, id string) error { } func (s *ApiKeyService) ValidateApiKey(ctx context.Context, rawKey string) (*models.User, error) { + user, _, err := s.ValidateApiKeyWithID(ctx, rawKey) + return user, err +} + +// ValidateApiKeyWithID is like ValidateApiKey but additionally returns the +// API key's database ID so callers can resolve per-key permissions. +func (s *ApiKeyService) ValidateApiKeyWithID(ctx context.Context, rawKey string) (*models.User, string, error) { keyPrefix, err := parseAPIKeyPrefixInternal(rawKey) if err != nil { - return nil, err + return nil, "", err } var apiKeys []models.ApiKey if err := s.db.WithContext(ctx).Where("key_prefix = ?", keyPrefix).Find(&apiKeys).Error; err != nil { - return nil, fmt.Errorf("failed to find API keys: %w", err) + return nil, "", fmt.Errorf("failed to find API keys: %w", err) } rawKey = normalizeAPIKeyInputInternal(rawKey) for _, apiKey := range apiKeys { if err := s.validateApiKeyHash(apiKey.KeyHash, rawKey); err == nil { if apiKey.ExpiresAt != nil && apiKey.ExpiresAt.Before(time.Now()) { - return nil, ErrApiKeyExpired + return nil, "", ErrApiKeyExpired } if apiKey.UserID == nil { - return nil, ErrApiKeyInvalid + return nil, "", ErrApiKeyInvalid } s.markApiKeyUsedAsync(ctx, apiKey.ID) user, err := s.userService.GetUserByID(ctx, *apiKey.UserID) if err != nil { - return nil, fmt.Errorf("failed to get user for API key: %w", err) + return nil, "", fmt.Errorf("failed to get user for API key: %w", err) } - return user, nil + return user, apiKey.ID, nil } } - return nil, ErrApiKeyInvalid + return nil, "", ErrApiKeyInvalid } func (s *ApiKeyService) GetEnvironmentByApiKey(ctx context.Context, rawKey string) (*string, error) { diff --git a/backend/internal/services/api_key_service_test.go b/backend/internal/services/api_key_service_test.go index f7b6cbf715..22204def6e 100644 --- a/backend/internal/services/api_key_service_test.go +++ b/backend/internal/services/api_key_service_test.go @@ -14,6 +14,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/types/apikey" ) @@ -47,7 +48,6 @@ func createTestAPIKeyUser(t *testing.T, ctx context.Context, userService *UserSe user := &models.User{ BaseModel: models.BaseModel{ID: id}, Username: fmt.Sprintf("user-%s", id), - Roles: models.StringSlice{"admin"}, } created, err := userService.CreateUser(ctx, user) @@ -118,7 +118,6 @@ func createDefaultAdminUser(t *testing.T, ctx context.Context, userService *User user := &models.User{ BaseModel: models.BaseModel{ID: "default-admin-user"}, Username: defaultAdminUsername, - Roles: models.StringSlice{"admin"}, } created, err := userService.CreateUser(ctx, user) @@ -191,7 +190,7 @@ func TestUpdateApiKeyRejectsStaticKey(t *testing.T) { created, err := service.CreateDefaultAdminAPIKey(ctx, adminUser.ID, "arc_bootstrapupdateprotected1234567890") require.NoError(t, err) - updated, err := service.UpdateApiKey(ctx, created.ApiKey.ID, apikey.UpdateApiKey{ + updated, err := service.UpdateApiKey(ctx, adminUser.ID, created.ApiKey.ID, apikey.UpdateApiKey{ Name: new("renamed"), Description: new("updated description"), }) @@ -205,6 +204,35 @@ func TestUpdateApiKeyRejectsStaticKey(t *testing.T) { require.Equal(t, *defaultAdminAPIKeyDescription, *apiKeys[0].Description) } +func TestUpdateApiKeyRollsBackMetadataWhenPermissionUpdateFails(t *testing.T) { + ctx := context.Background() + db := setupAuthServiceTestDB(t) + require.NoError(t, db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, ''))").Error) + + roleSvc := NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(ctx)) + userSvc := NewUserService(db).WithRoleService(roleSvc) + service := NewApiKeyService(db, userSvc).WithRoleService(roleSvc) + admin := createTestUser(t, userSvc, "admin-update-rollback", "admin-update-rollback") + grantGlobalAdmin(t, roleSvc, admin.ID) + + created, err := service.CreateApiKey(ctx, admin.ID, apikey.CreateApiKey{Name: "original"}) + require.NoError(t, err) + + updated, err := service.UpdateApiKey(ctx, admin.ID, created.ApiKey.ID, apikey.UpdateApiKey{ + Name: new("renamed"), + Permissions: []apikey.PermissionGrant{ + {Permission: authz.PermContainersList}, + {Permission: authz.PermContainersList}, + }, + }) + require.Nil(t, updated) + require.Error(t, err) + + stored := fetchAPIKey(t, db, created.ApiKey.ID) + require.Equal(t, "original", stored.Name) +} + func TestReconcileDefaultAdminAPIKeyNoOpWhenUnchanged(t *testing.T) { ctx := context.Background() service, db, userService := setupAPIKeyService(t) @@ -407,6 +435,66 @@ func TestGetEnvironmentByAPIKeyExpiredDoesNotUpdateLastUsedAt(t *testing.T) { require.Nil(t, apiKey.LastUsedAt) } +func TestCreateEnvironmentApiKeySeedsAllPermissionsScopedToEnv(t *testing.T) { + ctx := context.Background() + db := setupAuthServiceTestDB(t) + require.NoError(t, db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, ''))").Error) + + roleSvc := NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(ctx)) + userSvc := NewUserService(db).WithRoleService(roleSvc) + service := NewApiKeyService(db, userSvc).WithRoleService(roleSvc) + admin := createTestUser(t, userSvc, "admin-env-bootstrap", "admin-env-bootstrap") + grantGlobalAdmin(t, roleSvc, admin.ID) + + envID := "env-bootstrap-test" + created, err := service.CreateEnvironmentApiKey(ctx, envID, admin.ID) + require.NoError(t, err) + + // Resolve the per-key permission set and confirm every permission is + // present, scoped to the bootstrap env (not global). + ps, err := roleSvc.ResolveApiKeyPermissions(ctx, created.ApiKey.ID) + require.NoError(t, err) + require.Empty(t, ps.Global, "bootstrap key permissions must land in PerEnv, not Global") + envPerms, ok := ps.PerEnv[envID] + require.True(t, ok) + for _, p := range authz.AllPermissions() { + _, has := envPerms[p] + require.True(t, has, "missing permission %s on bootstrap key", p) + } +} + +func TestBackfillApiKeyPermissionsRepairsExistingBootstrapKey(t *testing.T) { + ctx := context.Background() + db := setupAuthServiceTestDB(t) + require.NoError(t, db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, ''))").Error) + + roleSvc := NewRoleService(db) + require.NoError(t, roleSvc.EnsureBuiltInRoles(ctx)) + + // Simulate a pre-existing env-bootstrap key with NO permission grants + // (e.g., created on a deployment where the per-key seed step failed). + envID := "env-broken-bootstrap" + require.NoError(t, db.WithContext(ctx).Create(&models.ApiKey{ + Name: "Environment Bootstrap Key - broken", + KeyHash: "hash", + KeyPrefix: "arc_brkn", + EnvironmentID: &envID, + }).Error) + + require.NoError(t, roleSvc.BackfillApiKeyPermissions(ctx)) + + // The backfill should have populated the env-scoped perms retroactively. + var keys []models.ApiKey + require.NoError(t, db.WithContext(ctx).Where("environment_id = ?", envID).Find(&keys).Error) + require.Len(t, keys, 1) + ps, err := roleSvc.ResolveApiKeyPermissions(ctx, keys[0].ID) + require.NoError(t, err) + envPerms, ok := ps.PerEnv[envID] + require.True(t, ok) + require.Equal(t, len(authz.AllPermissions()), len(envPerms)) +} + func TestGetEnvironmentByAPIKeyRecentLastUsedAtDoesNotRewriteImmediately(t *testing.T) { ctx := context.Background() service, db, userService := setupAPIKeyService(t) diff --git a/backend/internal/services/apprise_service.go b/backend/internal/services/apprise_service.go deleted file mode 100644 index 6cf858e085..0000000000 --- a/backend/internal/services/apprise_service.go +++ /dev/null @@ -1,255 +0,0 @@ -package services - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "time" - - "github.com/getarcaneapp/arcane/backend/internal/config" - "github.com/getarcaneapp/arcane/backend/internal/database" - "github.com/getarcaneapp/arcane/backend/internal/models" - "github.com/getarcaneapp/arcane/backend/pkg/utils/notifications" - "github.com/getarcaneapp/arcane/types/imageupdate" - "gorm.io/gorm" -) - -// AppriseService handles sending notifications through Apprise API -// -// Deprecated: Built-in providers (e.g., SMTP via Shoutrrr) are preferred. -type AppriseService struct { - db *database.DB - config *config.Config -} - -func NewAppriseService(db *database.DB, cfg *config.Config) *AppriseService { - return &AppriseService{ - db: db, - config: cfg, - } -} - -type AppriseNotificationPayload struct { - Body string `json:"body"` - Title string `json:"title,omitempty"` - Type string `json:"type,omitempty"` - Tag []string `json:"tag,omitempty"` - Format string `json:"format,omitempty"` -} - -func (s *AppriseService) GetSettings(ctx context.Context) (*models.AppriseSettings, error) { - var settings models.AppriseSettings - if err := s.db.WithContext(ctx).First(&settings).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, err - } - return &settings, nil -} - -func (s *AppriseService) CreateOrUpdateSettings(ctx context.Context, apiURL string, enabled bool, imageUpdateTag, containerUpdateTag string) (*models.AppriseSettings, error) { - var settings models.AppriseSettings - - err := s.db.WithContext(ctx).First(&settings).Error - if err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to check apprise settings: %w", err) - } - settings = models.AppriseSettings{ - APIURL: apiURL, - Enabled: enabled, - ImageUpdateTag: imageUpdateTag, - ContainerUpdateTag: containerUpdateTag, - } - if err := s.db.WithContext(ctx).Create(&settings).Error; err != nil { - return nil, fmt.Errorf("failed to create apprise settings: %w", err) - } - } else { - settings.APIURL = apiURL - settings.Enabled = enabled - settings.ImageUpdateTag = imageUpdateTag - settings.ContainerUpdateTag = containerUpdateTag - if err := s.db.WithContext(ctx).Save(&settings).Error; err != nil { - return nil, fmt.Errorf("failed to update apprise settings: %w", err) - } - } - - return &settings, nil -} - -func (s *AppriseService) SendNotification(ctx context.Context, title, body, format string, notificationType models.NotificationEventType) error { - settings, err := s.GetSettings(ctx) - if err != nil { - return fmt.Errorf("failed to get apprise settings: %w", err) - } - - if settings == nil || !settings.Enabled { - return nil - } - - if settings.APIURL == "" { - return fmt.Errorf("apprise API URL not configured") - } - - var tags []string - switch notificationType { - case models.NotificationEventImageUpdate: - if settings.ImageUpdateTag != "" { - tags = []string{settings.ImageUpdateTag} - } - case models.NotificationEventContainerUpdate: - if settings.ContainerUpdateTag != "" { - tags = []string{settings.ContainerUpdateTag} - } - - case models.NotificationEventPruneReport: - // Handle tags for prune report if needed, or leave empty - - case models.NotificationEventVulnerabilityFound: - // No dedicated tag in AppriseSettings; notification is sent without a tag - - case models.NotificationEventAutoHeal: - // No dedicated tag in AppriseSettings; notification is sent without a tag - } - - payload := AppriseNotificationPayload{ - Title: title, - Body: body, - Type: "info", - Tag: tags, - Format: format, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal notification payload: %w", err) - } - - slog.InfoContext(ctx, "Sending Apprise notification", "url", settings.APIURL, "title", title, "tags", tags, "type", string(notificationType)) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, settings.APIURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) //nolint:gosec // intentional request to user-configured Apprise API endpoint - if err != nil { - return fmt.Errorf("failed to send notification: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Read response body for debugging - bodyBytes, _ := io.ReadAll(resp.Body) - bodyString := string(bodyBytes) - - if resp.StatusCode != http.StatusOK { - slog.ErrorContext(ctx, "Apprise API returned error", "status", resp.StatusCode, "response", bodyString, "url", settings.APIURL) - return fmt.Errorf("apprise API returned status %d: %s", resp.StatusCode, bodyString) - } - - slog.InfoContext(ctx, "Apprise notification sent successfully", "status", resp.StatusCode, "response", bodyString) - - return nil -} - -func (s *AppriseService) SendImageUpdateNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response) error { - title := fmt.Sprintf("[%s] Container Image Update Available: %s", environmentName, imageRef) - body := notifications.BuildImageUpdateNotificationMessage(notifications.MessageFormatPlain, environmentName, imageRef, updateInfo) - return s.SendNotification(ctx, title, body, "text", models.NotificationEventImageUpdate) -} - -func (s *AppriseService) SendContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string) error { - title := fmt.Sprintf("[%s] Container Updated: %s", environmentName, containerName) - body := notifications.BuildContainerUpdateNotificationMessage(notifications.MessageFormatPlain, environmentName, containerName, imageRef, oldDigest, newDigest) - return s.SendNotification(ctx, title, body, "text", models.NotificationEventContainerUpdate) -} - -func (s *AppriseService) SendBatchImageUpdateNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response) error { - if len(updates) == 0 { - return nil - } - - updatesWithChanges := make(map[string]*imageupdate.Response) - for imageRef, update := range updates { - if update != nil && update.HasUpdate { - updatesWithChanges[imageRef] = update - } - } - - if len(updatesWithChanges) == 0 { - return nil - } - - title := fmt.Sprintf("[%s] %d Container Image Update(s) Available", environmentName, len(updatesWithChanges)) - body := notifications.BuildBatchImageUpdateNotificationMessage(notifications.MessageFormatPlain, environmentName, updatesWithChanges) - - return s.SendNotification(ctx, title, body, "text", models.NotificationEventImageUpdate) -} - -func (s *AppriseService) TestNotification(ctx context.Context, environmentName, testType string) error { - switch testType { - case "vulnerability-found": - title := notifications.BuildEmailSubject(environmentName, "Vulnerability Summary Notification") - body := fmt.Sprintf( - "Summary Date: %s\nCritical: 1\nHigh: 3\nMedium: 2\nLow: 1\nUnknown: 0\nFixable vulnerabilities: 7\nExamples: CVE-2025-1234, CVE-2025-5678, CVE-2026-0001", - time.Now().UTC().Format("2006-01-02"), - ) - return s.SendNotification(ctx, title, body, "text", models.NotificationEventVulnerabilityFound) - case "prune-report": - title := notifications.BuildEmailSubject(environmentName, "System Prune Report") - body := "Containers pruned: 2\nImages deleted: 1\nVolumes deleted: 1\nNetworks deleted: 1\nSpace reclaimed: 3.56 GB" - return s.SendNotification(ctx, title, body, "text", models.NotificationEventPruneReport) - case "image-update": - testUpdate := &imageupdate.Response{ - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:abc123def456789012345678901234567890", - LatestDigest: "sha256:xyz789ghi012345678901234567890123456", - CheckTime: time.Now(), - ResponseTimeMs: 100, - } - return s.SendImageUpdateNotification(ctx, environmentName, "nginx:latest", testUpdate) - case "batch-image-update": - testUpdates := map[string]*imageupdate.Response{ - "nginx:latest": { - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:abc123def456789012345678901234567890", - LatestDigest: "sha256:xyz789ghi012345678901234567890123456", - CheckTime: time.Now(), - ResponseTimeMs: 100, - }, - "postgres:16-alpine": { - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:def456abc123789012345678901234567890", - LatestDigest: "sha256:ghi789xyz012345678901234567890123456", - CheckTime: time.Now(), - ResponseTimeMs: 120, - }, - "redis:7.2-alpine": { - HasUpdate: true, - UpdateType: "digest", - CurrentDigest: "sha256:123456789abc012345678901234567890def", - LatestDigest: "sha256:456789012def345678901234567890123abc", - CheckTime: time.Now(), - ResponseTimeMs: 95, - }, - } - return s.SendBatchImageUpdateNotification(ctx, environmentName, testUpdates) - case "simple", "": - title := notifications.BuildEmailSubject(environmentName, "Test Notification from Arcane") - body := "If you're reading this, your Apprise integration is working correctly!" - return s.SendNotification(ctx, title, body, "text", models.NotificationEventImageUpdate) - default: - return fmt.Errorf("unsupported apprise test type: %s", testType) - } -} diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 2752106d8b..291aa82eea 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -30,9 +30,9 @@ var ( ErrOidcAuthDisabled = errors.New("OIDC authentication is disabled") ) -type TokenPair struct { //nolint:gosec // API response contract intentionally includes token fields +type TokenPair struct { AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` //nolint:gosec // API response contract requires refreshToken field + RefreshToken string `json:"refreshToken"` ExpiresAt time.Time `json:"expiresAt"` } @@ -45,17 +45,20 @@ type AuthSettings struct { type userClaims struct { jwt.RegisteredClaims - SessionID string `json:"sid,omitempty"` - UserID string `json:"user_id"` - Username string `json:"username"` - Email string `json:"email,omitempty"` - DisplayName string `json:"display_name,omitempty"` - Roles []string `json:"roles"` - AppVersion string `json:"app_version,omitempty"` + + SessionID string `json:"sid,omitempty"` + UserID string `json:"user_id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + AppVersion string `json:"app_version,omitempty"` + TokenType string `json:"token_type,omitempty"` + FederatedCredentialID string `json:"federated_credential_id,omitempty"` } type refreshClaims struct { jwt.RegisteredClaims + UserID string `json:"user_id"` SessionID string `json:"sid,omitempty"` AppVersion string `json:"app_version,omitempty"` @@ -71,6 +74,7 @@ type AuthService struct { settingsService *SettingsService eventService *EventService sessionService *SessionService + roleService *RoleService jwtSecret []byte refreshExpiry time.Duration config *config.Config @@ -81,12 +85,13 @@ type AuthService struct { tokenCache *cache.TTL[verifiedTokenEntry] } -func NewAuthService(userService *UserService, settingsService *SettingsService, eventService *EventService, sessionService *SessionService, jwtSecret string, cfg *config.Config) *AuthService { +func NewAuthService(userService *UserService, settingsService *SettingsService, eventService *EventService, sessionService *SessionService, roleService *RoleService, jwtSecret string, cfg *config.Config) *AuthService { return &AuthService{ userService: userService, settingsService: settingsService, eventService: eventService, sessionService: sessionService, + roleService: roleService, jwtSecret: jwtclaims.CheckOrGenerateJwtSecret(jwtSecret), refreshExpiry: cfg.JWTRefreshExpiry, config: cfg, @@ -119,8 +124,7 @@ func (s *AuthService) getAuthSettings(ctx context.Context) (*AuthSettings, error JwksURI: settings.OidcJwksEndpoint.Value, DeviceAuthorizationEndpoint: settings.OidcDeviceAuthorizationEndpoint.Value, Scopes: settings.OidcScopes.Value, - AdminClaim: settings.OidcAdminClaim.Value, - AdminValue: settings.OidcAdminValue.Value, + GroupsClaim: settings.OidcGroupsClaim.Value, SkipTlsVerify: settings.OidcSkipTlsVerify.IsTrue(), } @@ -419,11 +423,6 @@ func (s *AuthService) createOidcUser(ctx context.Context, userInfo auth.OidcUser username = userInfo.PreferredUsername } - roles := models.StringSlice{"user"} - if s.isAdminFromOidc(ctx, userInfo, tokenResp) { - roles = append(roles, "admin") - } - var displayName *string switch { case userInfo.Name != "": @@ -439,7 +438,6 @@ func (s *AuthService) createOidcUser(ctx context.Context, userInfo auth.OidcUser Username: username, DisplayName: displayName, Email: new(userInfo.Email), - Roles: roles, OidcSubjectId: new(userInfo.Subject), LastLogin: new(time.Now()), } @@ -449,6 +447,9 @@ func (s *AuthService) createOidcUser(ctx context.Context, userInfo auth.OidcUser if _, err := s.userService.CreateUser(ctx, user); err != nil { return nil, err } + if err := s.syncOidcRoleAssignments(ctx, user, userInfo, tokenResp); err != nil { + slog.WarnContext(ctx, "failed to sync OIDC role assignments on user create", "error", err, "user_id", user.ID) + } return user, nil } @@ -460,116 +461,143 @@ func (s *AuthService) updateOidcUser(ctx context.Context, user *models.User, use user.Email = new(userInfo.Email) } - wantAdmin := s.isAdminFromOidc(ctx, userInfo, tokenResp) - hasAdmin := hasRole(user.Roles, "admin") - switch { - case wantAdmin && !hasAdmin: - user.Roles = addRole(user.Roles, "admin") - case !wantAdmin && hasAdmin: - user.Roles = removeRole(user.Roles, "admin") - } - s.persistOidcTokens(user, tokenResp) user.LastLogin = new(time.Now()) - _, err := s.userService.UpdateUser(ctx, user) - return err + if _, err := s.userService.UpdateUser(ctx, user); err != nil { + return err + } + if err := s.syncOidcRoleAssignments(ctx, user, userInfo, tokenResp); err != nil { + slog.WarnContext(ctx, "failed to sync OIDC role assignments on user update", "error", err, "user_id", user.ID) + } + return nil } func (s *AuthService) mergeOidcWithExistingUser(ctx context.Context, user *models.User, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) error { // Perform the merge atomically to avoid races when multiple OIDC subjects share the same email - _, err := s.userService.AttachOidcSubjectTransactional(ctx, user.ID, userInfo.Subject, func(u *models.User) { - // Update display name if not set + merged, err := s.userService.AttachOidcSubjectTransactional(ctx, user.ID, userInfo.Subject, func(u *models.User) { if userInfo.Name != "" && u.DisplayName == nil { u.DisplayName = new(userInfo.Name) } - - // Update admin role based on OIDC claims - wantAdmin := s.isAdminFromOidc(ctx, userInfo, tokenResp) - hasAdmin := hasRole(u.Roles, "admin") - switch { - case wantAdmin && !hasAdmin: - u.Roles = addRole(u.Roles, "admin") - case !wantAdmin && hasAdmin: - u.Roles = removeRole(u.Roles, "admin") - } - - // Persist OIDC tokens s.persistOidcTokens(u, tokenResp) - u.LastLogin = new(time.Now()) }) - return err -} - -func hasRole(roles models.StringSlice, role string) bool { - for _, r := range roles { - if strings.EqualFold(r, role) { - return true + if err != nil { + return err + } + if merged != nil { + if syncErr := s.syncOidcRoleAssignments(ctx, merged, userInfo, tokenResp); syncErr != nil { + slog.WarnContext(ctx, "failed to sync OIDC role assignments on user merge", "error", syncErr, "user_id", merged.ID) } } - return false + return nil } -func addRole(roles models.StringSlice, role string) models.StringSlice { - if hasRole(roles, role) { - return roles +// syncOidcRoleAssignments rebuilds the user's `source='oidc'` role assignments +// based on the OIDC group claim and the configured OidcRoleMapping rows. +// Manual assignments are untouched. +func (s *AuthService) syncOidcRoleAssignments(ctx context.Context, user *models.User, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) error { + if s.roleService == nil || user == nil { + return nil } - return append(roles, role) -} -func removeRole(roles models.StringSlice, role string) models.StringSlice { - out := make(models.StringSlice, 0, len(roles)) - for _, r := range roles { - if !strings.EqualFold(r, role) { - out = append(out, r) - } + groups := s.extractOidcGroups(ctx, userInfo, tokenResp) + mappings, err := s.roleService.ListOidcMappings(ctx) + if err != nil { + return fmt.Errorf("list oidc mappings: %w", err) } - return out -} -func (s *AuthService) isAdminFromOidc(ctx context.Context, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) bool { - claimKey, values := s.getAdminClaimConfig(ctx) - if claimKey == "" { - return false + groupSet := make(map[string]struct{}, len(groups)) + for _, g := range groups { + groupSet[g] = struct{}{} } - if v, ok := jwtclaims.GetByPath(userInfo.Extra, claimKey); ok && jwtclaims.EvalMatch(v, values) { - return true + var desired []models.UserRoleAssignment + seen := make(map[string]struct{}) // dedup by roleID|envID + for _, m := range mappings { + if _, ok := groupSet[m.ClaimValue]; !ok { + continue + } + key := m.RoleID + "|" + if m.EnvironmentID != nil { + key += *m.EnvironmentID + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + desired = append(desired, models.UserRoleAssignment{ + RoleID: m.RoleID, + EnvironmentID: m.EnvironmentID, + }) } - if tokenResp != nil && tokenResp.IDToken != "" { - if claims := jwtclaims.ParseJWTClaims(tokenResp.IDToken); claims != nil { - if v, ok := jwtclaims.GetByPath(claims, claimKey); ok && jwtclaims.EvalMatch(v, values) { - return true + return s.roleService.ReplaceOidcAssignments(ctx, user.ID, desired) +} + +// extractOidcGroups reads the user's group memberships from the OIDC userinfo +// and ID token, using the claim path configured in OidcGroupsClaim (defaults +// to "groups"). Falls back to userInfo.Groups if no value is found at the +// configured path. +func (s *AuthService) extractOidcGroups(ctx context.Context, userInfo auth.OidcUserInfo, tokenResp *auth.OidcTokenResponse) []string { + claim := s.oidcGroupsClaim(ctx) + + if claim != "" { + if v, ok := jwtclaims.GetByPath(userInfo.Extra, claim); ok { + if groups := stringValuesFromClaim(v); len(groups) > 0 { + return groups + } + } + if tokenResp != nil && tokenResp.IDToken != "" { + if parsed := jwtclaims.ParseJWTClaims(tokenResp.IDToken); parsed != nil { + if v, ok := jwtclaims.GetByPath(parsed, claim); ok { + if groups := stringValuesFromClaim(v); len(groups) > 0 { + return groups + } + } } } } - return false + return userInfo.Groups } -func (s *AuthService) getAdminClaimConfig(ctx context.Context) (claim string, values []string) { - as, err := s.getAuthSettings(ctx) - if err != nil || as.Oidc == nil { - return "", nil - } - claim = strings.TrimSpace(as.Oidc.AdminClaim) - raw := strings.TrimSpace(as.Oidc.AdminValue) - if claim == "" { - return "", nil +func (s *AuthService) oidcGroupsClaim(ctx context.Context) string { + settings, err := s.settingsService.GetSettings(ctx) + if err != nil { + return "groups" } - if raw == "" { - return claim, nil + v := strings.TrimSpace(settings.OidcGroupsClaim.Value) + if v == "" { + return "groups" } - parts := strings.SplitSeq(raw, ",") - for p := range parts { - v := strings.TrimSpace(p) - if v != "" { - values = append(values, v) + return v +} + +// stringValuesFromClaim flattens a claim value into a slice of strings. +// Accepts string, []string, []any (coerces each element to string), or nil. +func stringValuesFromClaim(v any) []string { + switch typed := v.(type) { + case nil: + return nil + case string: + if typed == "" { + return nil } + return []string{typed} + case []string: + return typed + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil } - return claim, values } func (s *AuthService) persistOidcTokens(user *models.User, tokenResp *auth.OidcTokenResponse) { @@ -698,8 +726,7 @@ func (s *AuthService) VerifyToken(ctx context.Context, accessToken string) (*mod tokenHash := hashTokenInternal(accessToken) if cached, ok := s.tokenCache.Get(tokenHash); ok { - u := cached.User - return &u, cached.SessionID, nil + return new(cached.User), cached.SessionID, nil } // Verify user exists in DB @@ -786,6 +813,18 @@ func (s *AuthService) RevokeSession(ctx context.Context, sessionID string) error return s.sessionService.RevokeSession(ctx, sessionID) } +// LogoutAllOtherSessions revokes every active session for userID except +// currentSessionID, so the caller stays signed in on their current device. +func (s *AuthService) LogoutAllOtherSessions(ctx context.Context, userID, currentSessionID string) error { + if s.sessionService == nil { + return nil + } + s.tokenCache.DeleteFunc(func(_ string, e verifiedTokenEntry) bool { + return e.User.ID == userID && e.SessionID != currentSessionID + }) + return s.sessionService.RevokeAllUserSessionsExcept(ctx, userID, currentSessionID) +} + func (s *AuthService) createSessionAndTokensInternal(ctx context.Context, user *models.User, meta auth.SessionMeta) (*TokenPair, error) { if s.sessionService == nil { return nil, &common.SessionServiceUnavailableError{} @@ -813,7 +852,6 @@ func (s *AuthService) buildTokenPairInternal(ctx context.Context, user *models.U SessionID: session.ID, UserID: user.ID, Username: user.Username, - Roles: []string(user.Roles), AppVersion: config.Version, } @@ -856,6 +894,70 @@ func (s *AuthService) buildTokenPairInternal(ctx context.Context, user *models.U }, nil } +func (s *AuthService) IssueFederatedToken(ctx context.Context, user *models.User, credentialID string, ttlSeconds int) (*TokenPair, error) { + if s.sessionService == nil { + return nil, &common.SessionServiceUnavailableError{} + } + if user == nil { + return nil, ErrUserNotFound + } + + ttlSeconds = clampFederatedTokenTTLSecondsInternal(ttlSeconds) + now := time.Now() + accessTokenExpiry := now.Add(time.Duration(ttlSeconds) * time.Second) + + session, err := s.sessionService.CreateFederatedSession(ctx, user.ID, accessTokenExpiry, credentialID) + if err != nil { + return nil, err + } + + claims := userClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: user.ID, + Subject: "access", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(accessTokenExpiry), + }, + SessionID: session.ID, + UserID: user.ID, + Username: user.Username, + AppVersion: config.Version, + TokenType: models.UserSessionSourceFederated, + FederatedCredentialID: credentialID, + } + + if user.Email != nil { + claims.Email = *user.Email + } + if user.DisplayName != nil { + claims.DisplayName = *user.DisplayName + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessTokenString, err := accessToken.SignedString(s.jwtSecret) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessTokenString, + ExpiresAt: accessTokenExpiry, + }, nil +} + +func clampFederatedTokenTTLSecondsInternal(ttlSeconds int) int { + if ttlSeconds <= 0 { + return 900 + } + if ttlSeconds < 60 { + return 60 + } + if ttlSeconds > 3600 { + return 3600 + } + return ttlSeconds +} + func validateSessionActiveInternal(session *models.UserSession) error { if session == nil { return ErrInvalidToken diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index 18dbcc8ec7..d4c88af154 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -24,7 +24,17 @@ func setupAuthServiceTestDB(t *testing.T) *database.DB { t.Helper() db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.SettingVariable{}, &models.User{}, &models.UserSession{})) + require.NoError(t, db.AutoMigrate( + &models.SettingVariable{}, + &models.User{}, + &models.UserSession{}, + &models.Environment{}, + &models.Role{}, + &models.UserRoleAssignment{}, + &models.ApiKey{}, + &models.ApiKeyPermission{}, + &models.OidcRoleMapping{}, + )) return &database.DB{DB: db} } @@ -49,7 +59,7 @@ func newTestAuthService(secret string) *AuthService { } } -func makeAccessToken(t *testing.T, secret []byte, subject string, id string, username string, roles []string, email, displayName string, exp time.Time, sessionIDs ...string) string { +func makeAccessToken(t *testing.T, secret []byte, subject string, id string, username string, _ []string, email, displayName string, exp time.Time, sessionIDs ...string) string { t.Helper() sessionID := "" if len(sessionIDs) > 0 { @@ -65,7 +75,6 @@ func makeAccessToken(t *testing.T, secret []byte, subject string, id string, use SessionID: sessionID, UserID: id, Username: username, - Roles: roles, Email: email, DisplayName: displayName, AppVersion: config.Version, @@ -141,7 +150,6 @@ func TestVerifyToken_ValidClaims(t *testing.T) { Username: "alice", Email: new("a@example.com"), DisplayName: new("Alice"), - Roles: models.StringSlice{"user", "admin"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -160,9 +168,6 @@ func TestVerifyToken_ValidClaims(t *testing.T) { if verifiedUser.Username != "alice" { t.Errorf("username %q", verifiedUser.Username) } - if len(verifiedUser.Roles) != 2 || verifiedUser.Roles[0] != "user" || verifiedUser.Roles[1] != "admin" { - t.Errorf("roles %v", verifiedUser.Roles) - } if verifiedUser.Email == nil || *verifiedUser.Email != "a@example.com" { t.Errorf("email %v", verifiedUser.Email) } @@ -183,7 +188,6 @@ func TestVerifyToken_RejectsNonHMACAlg(t *testing.T) { }, UserID: "u1", Username: "bob", - Roles: []string{"user"}, AppVersion: config.Version, }) @@ -204,7 +208,6 @@ func TestVerifyToken_Expired(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u1"}, Username: "bob", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -325,7 +328,6 @@ func TestRefreshToken_Valid(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-refresh"}, Username: "refresh-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -357,7 +359,6 @@ func TestRefreshToken_VersionMismatchRotates(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-versionmismatch"}, Username: "versionmismatch-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -404,7 +405,6 @@ func TestVerifyToken_RejectsRevokedSession(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-revoked"}, Username: "revoked-user", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -428,7 +428,6 @@ func TestVerifyToken_RejectsMissingSessionID(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-no-sid"}, Username: "no-sid-user", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -449,7 +448,6 @@ func TestRevokeSessionThenVerifyTokenFails(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-logout"}, Username: "logout-user", - Roles: models.StringSlice{"user"}, } _, err := userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -476,7 +474,6 @@ func TestRefreshToken_RotatesJTI(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-rotate"}, Username: "rotate-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -506,7 +503,6 @@ func TestRefreshToken_RejectsRevokedSession(t *testing.T) { user := &models.User{ BaseModel: models.BaseModel{ID: "u-refresh-revoked"}, Username: "refresh-revoked-user", - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -533,7 +529,6 @@ func TestChangePassword_RevokesAllSessions(t *testing.T) { BaseModel: models.BaseModel{ID: "u-password"}, Username: "password-user", PasswordHash: passwordHash, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -564,7 +559,6 @@ func TestChangePassword_KeepsCurrentSessionAlive(t *testing.T) { BaseModel: models.BaseModel{ID: "u-keep"}, Username: "keep-user", PasswordHash: passwordHash, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(context.Background(), user) require.NoError(t, err) @@ -706,7 +700,6 @@ func TestFindOrCreateOidcUser_MergeEnabled_EmailNotVerified_WithExistingUser_Ret BaseModel: models.BaseModel{ID: "u1"}, Username: "existing", Email: &email, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(ctx, existing) require.NoError(t, err) @@ -749,7 +742,6 @@ func TestFindOrCreateOidcUser_MergeEnabled_EmailVerificationMissing_WithExisting BaseModel: models.BaseModel{ID: "u1"}, Username: "existing", Email: &email, - Roles: models.StringSlice{"user"}, } _, err = userSvc.CreateUser(ctx, existing) require.NoError(t, err) diff --git a/backend/internal/services/build_service.go b/backend/internal/services/build_service.go index 5c86e4213b..ffb1db3847 100644 --- a/backend/internal/services/build_service.go +++ b/backend/internal/services/build_service.go @@ -16,7 +16,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" buildgit "github.com/getarcaneapp/arcane/backend/pkg/gitutil" - libbuild "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" "github.com/getarcaneapp/arcane/backend/pkg/pagination" buildtypes "github.com/getarcaneapp/arcane/types/builds" imagetypes "github.com/getarcaneapp/arcane/types/image" @@ -155,12 +155,10 @@ func (s *BuildService) BuildImage(ctx context.Context, environmentID string, req var errMsg *string if err != nil { status = models.ImageBuildStatusFailed - errorText := err.Error() - errMsg = &errorText + errMsg = new(err.Error()) } - durationMs := completedAt.Sub(startedAt).Milliseconds() - if updateErr := s.completeBuildRecord(ctx, buildRecordID, status, outputPtr, logCapture.Truncated(), errMsg, digest, provider, completedAt, &durationMs); updateErr != nil { + if updateErr := s.completeBuildRecord(ctx, buildRecordID, status, outputPtr, logCapture.Truncated(), errMsg, digest, provider, completedAt, new(completedAt.Sub(startedAt).Milliseconds())); updateErr != nil { slog.WarnContext(ctx, "failed to update build history record", "error", updateErr) } } @@ -275,17 +273,17 @@ func (s *BuildService) resolveBuildRequestInternal( return req, func() error { return nil }, nil } - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("resolving remote git context %s", source.RepositoryURL)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "resolving remote git context "+source.RepositoryURL) authConfig, matchedRepository, err := s.resolveGitBuildAuthInternal(ctx, source.RepositoryURL) if err != nil { return imagetypes.BuildRequest{}, func() error { return nil }, err } if matchedRepository { - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("using saved git credentials for %s", source.RepositoryURL)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "using saved git credentials for "+source.RepositoryURL) } if libbuild.RequiresGitRemoteProbe(source.RepositoryURL) { - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("verifying remote git repository %s", source.RepositoryURL)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "verifying remote git repository "+source.RepositoryURL) if err := s.probeGitContextInternal(ctx, source.RepositoryURL, authConfig); err != nil { return imagetypes.BuildRequest{}, func() error { return nil }, fmt.Errorf("failed to verify remote git repository %q: %w", source.RepositoryURL, err) } @@ -312,10 +310,10 @@ func (s *BuildService) resolveBuildRequestInternal( } if !info.IsDir() { _ = s.cleanupGitContextInternal(repoPath) - return imagetypes.BuildRequest{}, func() error { return nil }, fmt.Errorf("resolved git build context is not a directory") + return imagetypes.BuildRequest{}, func() error { return nil }, errors.New("resolved git build context is not a directory") } - writeBuildProgressStatusInternal(progressWriter, serviceName, fmt.Sprintf("using remote build context %s", source.Raw)) + writeBuildProgressStatusInternal(progressWriter, serviceName, "using remote build context "+source.Raw) resolvedReq := req resolvedReq.ContextDir = contextDir @@ -397,7 +395,7 @@ func writeBuildProgressStatusInternal(progressWriter io.Writer, serviceName, sta func (s *BuildService) ListImageBuildsByEnvironmentPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]imagetypes.BuildRecord, pagination.Response, error) { if s.db == nil { - return nil, pagination.Response{}, fmt.Errorf("build history not available") + return nil, pagination.Response{}, errors.New("build history not available") } var builds []models.ImageBuild @@ -433,7 +431,7 @@ func (s *BuildService) ListImageBuildsByEnvironmentPaginated(ctx context.Context func (s *BuildService) GetImageBuildByID(ctx context.Context, environmentID, buildID string) (*imagetypes.BuildRecord, error) { if s.db == nil { - return nil, fmt.Errorf("build history not available") + return nil, errors.New("build history not available") } var build models.ImageBuild @@ -527,7 +525,7 @@ func (s *BuildService) completeBuildRecord( return fmt.Errorf("failed to update build record: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("build record not found") + return errors.New("build record not found") } return nil }) diff --git a/backend/internal/services/build_workspace_service.go b/backend/internal/services/build_workspace_service.go index da38564b9a..77a72c1a6a 100644 --- a/backend/internal/services/build_workspace_service.go +++ b/backend/internal/services/build_workspace_service.go @@ -109,7 +109,7 @@ func (s *BuildWorkspaceService) GetFileContent(ctx context.Context, filePath str return nil, "", fmt.Errorf("failed to stat file: %w", err) } if info.IsDir() { - return nil, "", fmt.Errorf("path is a directory") + return nil, "", errors.New("path is a directory") } if maxBytes <= 0 { @@ -153,7 +153,7 @@ func (s *BuildWorkspaceService) DownloadFile(ctx context.Context, filePath strin return nil, 0, fmt.Errorf("failed to stat file: %w", err) } if info.IsDir() { - return nil, 0, fmt.Errorf("path is a directory") + return nil, 0, errors.New("path is a directory") } file, err := os.Open(fullPath) @@ -245,7 +245,7 @@ func (s *BuildWorkspaceService) DeleteFile(ctx context.Context, filePath string) } if cleaned == "/" { - return fmt.Errorf("cannot delete root directory") + return errors.New("cannot delete root directory") } fullPath, err := joinBuildRoot(root, cleaned) @@ -271,7 +271,7 @@ func (s *BuildWorkspaceService) resolveRoot() (string, error) { } if !filepath.IsAbs(root) { - return "", fmt.Errorf("builds directory must be an absolute path") + return "", errors.New("builds directory must be an absolute path") } cleaned := filepath.Clean(root) @@ -293,10 +293,10 @@ func sanitizeBuildPath(input string) (string, error) { cleaned = "/" + cleaned } if strings.Contains(cleaned, "/../") || strings.HasSuffix(cleaned, "/..") || cleaned == "/.." { - return "", fmt.Errorf("invalid path: path traversal not allowed") + return "", errors.New("invalid path: path traversal not allowed") } if !strings.HasPrefix(cleaned, "/") { - return "", fmt.Errorf("invalid path: must be absolute") + return "", errors.New("invalid path: must be absolute") } return cleaned, nil @@ -305,25 +305,25 @@ func sanitizeBuildPath(input string) (string, error) { func sanitizeUploadFilename(filename string) (string, error) { name := strings.TrimSpace(filename) if name == "" { - return "", fmt.Errorf("invalid filename") + return "", errors.New("invalid filename") } // Reject any path separators (handle both Unix and Windows-style separators). if strings.Contains(name, "/") || strings.Contains(name, "\\") { - return "", fmt.Errorf("invalid filename: must not contain path separators") + return "", errors.New("invalid filename: must not contain path separators") } // On Windows, disallow drive/volume prefixes (e.g. C: or \\server\share). if vol := filepath.VolumeName(name); vol != "" { - return "", fmt.Errorf("invalid filename: must not include volume prefix") + return "", errors.New("invalid filename: must not include volume prefix") } base := filepath.Base(name) if base != name { - return "", fmt.Errorf("invalid filename: must not contain path separators") + return "", errors.New("invalid filename: must not contain path separators") } if base == "." || base == ".." { - return "", fmt.Errorf("invalid filename") + return "", errors.New("invalid filename") } return base, nil @@ -333,7 +333,7 @@ func joinBuildRoot(root, cleaned string) (string, error) { rel := strings.TrimPrefix(cleaned, "/") fullPath := filepath.Join(root, filepath.FromSlash(rel)) if !isWithinRoot(root, fullPath) { - return "", fmt.Errorf("invalid path: outside builds directory") + return "", errors.New("invalid path: outside builds directory") } return fullPath, nil } diff --git a/backend/internal/services/container_registry_service.go b/backend/internal/services/container_registry_service.go index 00aa105abb..7e8a8c2af6 100644 --- a/backend/internal/services/container_registry_service.go +++ b/backend/internal/services/container_registry_service.go @@ -11,7 +11,7 @@ import ( "sync" "time" - backoff "github.com/cenkalti/backoff/v5" + "github.com/cenkalti/backoff/v5" "golang.org/x/sync/singleflight" cerrdefs "github.com/containerd/errdefs" @@ -23,7 +23,6 @@ import ( "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/backend/pkg/utils/cache" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/containerregistry" dockerregistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" @@ -99,25 +98,14 @@ func (s *ContainerRegistryService) GetRegistriesPaginated(ctx context.Context, p var registries []models.ContainerRegistry q := s.db.WithContext(ctx).Model(&models.ContainerRegistry{}) - if term := strings.TrimSpace(params.Search); term != "" { - searchPattern := "%" + term + "%" - q = q.Where( - "url LIKE ? OR username LIKE ? OR COALESCE(description, '') LIKE ?", - searchPattern, searchPattern, searchPattern, - ) - } + q = pagination.ApplyLikeSearch(q, params.Search, "url LIKE ? OR username LIKE ? OR COALESCE(description, '') LIKE ?") q = pagination.ApplyBooleanFilter(q, "enabled", params.Filters["enabled"]) q = pagination.ApplyBooleanFilter(q, "insecure", params.Filters["insecure"]) - paginationResp, err := pagination.PaginateAndSortDB(params, q, ®istries) + out, paginationResp, err := pagination.PaginateSortAndMapDB[models.ContainerRegistry, containerregistry.ContainerRegistry](params, q, ®istries) if err != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to paginate container registries: %w", err) - } - - out, mapErr := mapper.MapSlice[models.ContainerRegistry, containerregistry.ContainerRegistry](registries) - if mapErr != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to map registries: %w", mapErr) + return nil, pagination.Response{}, fmt.Errorf("failed to list container registries: %w", err) } return out, paginationResp, nil @@ -404,7 +392,7 @@ func (s *ContainerRegistryService) GetAllRegistryAuthConfigs(ctx context.Context Password: token, ServerAddress: serverAddress, } - for _, key := range utilsregistry.RegistryAuthLookupKeys(normalizedHost) { + for _, key := range utilsregistry.LookupKeys(normalizedHost) { authConfigs[key] = authConfig } } @@ -720,7 +708,7 @@ func (s *ContainerRegistryService) GetImageDigest(ctx context.Context, imageRef return result.Digest, nil }) - var staleErr *cache.ErrStale + var staleErr *cache.StaleError if err != nil && !errors.As(err, &staleErr) { return "", err } diff --git a/backend/internal/services/container_service.go b/backend/internal/services/container_service.go index 2677f2ba9e..fe1c86c05b 100644 --- a/backend/internal/services/container_service.go +++ b/backend/internal/services/container_service.go @@ -1,7 +1,6 @@ package services import ( - "bufio" "context" "encoding/json" "errors" @@ -27,7 +26,6 @@ import ( containertypes "github.com/getarcaneapp/arcane/types/container" "github.com/getarcaneapp/arcane/types/containerregistry" imagetypes "github.com/getarcaneapp/arcane/types/image" - "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/events" "github.com/moby/moby/api/types/network" @@ -146,6 +144,17 @@ func shouldStartRedeployedContainerInternal(containerInfo container.InspectRespo return shouldStart } +func writeContainerProgressInternal(ctx context.Context, message string, progress int, phase string) { + progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) + if progressWriter == nil { + return + } + payload := fmt.Sprintf(`{"type":"container","phase":%q,"status":%q,"progressDetail":{"current":%d,"total":100}}`+"\n", phase, message, progress) + if _, err := progressWriter.Write([]byte(payload)); err != nil { + slog.DebugContext(ctx, "failed to write container progress", "phase", phase, "error", err) + } +} + func (s *ContainerService) pullRedeployImageInternal(ctx context.Context, dockerClient *client.Client, imageName, containerID, containerName string, user models.User) error { settings := s.settingsService.GetSettingsConfig() pullCtx, pullCancel := timeouts.WithTimeout(ctx, settings.DockerImagePullTimeout.AsInt(), timeouts.DefaultDockerImagePull) @@ -276,7 +285,7 @@ func (s *ContainerService) StartContainer(ctx context.Context, containerID strin err = s.eventService.LogContainerEvent(ctx, models.EventTypeContainerStart, containerID, "name", user.ID, user.Username, "0", metadata) if err != nil { - fmt.Printf("Could not log container start action: %s\n", err) + slog.WarnContext(ctx, "could not log container start action", "error", err) } _, err = dockerClient.ContainerStart(ctx, containerID, client.ContainerStartOptions{}) @@ -354,8 +363,8 @@ func (s *ContainerService) tryRedeployViaComposeProjectInternal(ctx context.Cont return "", false, nil } labels := containerInfo.Config.Labels - projectName := strings.TrimSpace(labels["com.docker.compose.project"]) - serviceName := strings.TrimSpace(labels["com.docker.compose.service"]) + projectName := dockerutils.ComposeProjectLabel(labels) + serviceName := dockerutils.ComposeServiceLabel(labels) if projectName == "" || serviceName == "" { return "", false, nil } @@ -519,12 +528,14 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st } if imageName != "" { + writeContainerProgressInternal(ctx, "Pulling latest container image", 20, "pull") if err := s.pullRedeployImageInternal(ctx, dockerClient, imageName, containerID, containerName, user); err != nil { return "", err } } backupName := buildRedeployBackupNameInternal(containerName, containerID) + writeContainerProgressInternal(ctx, "Preparing existing container", 45, "prepare") if err := s.prepareContainerForRedeployInternal(ctx, dockerClient, containerID, containerName, backupName, wasRunning, user); err != nil { return "", err } @@ -536,6 +547,7 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st newConfig.Hostname = "" } + writeContainerProgressInternal(ctx, "Creating replacement container", 65, "create") createResp, err := libarcane.ContainerCreateWithCompatibilityForAPIVersion(ctx, dockerClient, client.ContainerCreateOptions{ Config: &newConfig, HostConfig: containerInfo.HostConfig, @@ -553,6 +565,7 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st } if shouldStartRedeployedContainerInternal(containerInfo, wasRunning) { + writeContainerProgressInternal(ctx, "Starting replacement container", 80, "start") _, err = dockerClient.ContainerStart(ctx, createResp.ID, client.ContainerStartOptions{}) if err != nil { if _, removeErr := dockerClient.ContainerRemove(ctx, createResp.ID, client.ContainerRemoveOptions{Force: true}); removeErr != nil { @@ -594,6 +607,7 @@ func (s *ContainerService) RedeployContainer(ctx context.Context, containerID st slog.WarnContext(ctx, "failed to log deploy event", "err", logErr) } + writeContainerProgressInternal(ctx, "Container redeployed", 100, "complete") return createResp.ID, nil } @@ -753,7 +767,7 @@ func (s *ContainerService) CreateContainer(ctx context.Context, config *containe } if logErr := s.eventService.LogContainerEvent(ctx, models.EventTypeContainerCreate, resp.ID, "name", user.ID, user.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log container stop action: %s\n", logErr) + slog.WarnContext(ctx, "could not log container stop action", "error", logErr) } if _, err := dockerClient.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { @@ -854,97 +868,7 @@ func (s *ContainerService) StreamLogs(ctx context.Context, containerID string, l defer func() { _ = logs.Close() }() isTTY := containerInspect.Container.Config != nil && containerInspect.Container.Config.Tty - return s.streamContainerLogsInternal(ctx, logs, logsChan, follow, isTTY) -} - -func (s *ContainerService) streamContainerLogsInternal(ctx context.Context, logs io.ReadCloser, logsChan chan<- string, follow bool, isTTY bool) error { - if isTTY { - return s.streamRawLogsInternal(ctx, logs, logsChan) - } - if follow { - return streamMultiplexedLogs(ctx, logs, logsChan) - } - return s.readAllLogs(ctx, logs, logsChan) -} - -func (s *ContainerService) streamRawLogsInternal(ctx context.Context, logs io.Reader, logsChan chan<- string) error { - return s.readLogsFromReader(ctx, logs, logsChan, "") -} - -// readLogsFromReader reads logs line by line from a reader -func (s *ContainerService) readLogsFromReader(ctx context.Context, reader io.Reader, logsChan chan<- string, prefix string) error { - bufferedReader := bufio.NewReader(reader) - - for { - if err := ctx.Err(); err != nil { - return err - } - - line, err := bufferedReader.ReadString('\n') - if len(line) > 0 { - trimmed := strings.TrimRight(line, "\r\n") - if trimmed != "" { - if prefix != "" { - trimmed = prefix + trimmed - } - - select { - case logsChan <- trimmed: - case <-ctx.Done(): - return ctx.Err() - } - } - } - - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func (s *ContainerService) readAllLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string) error { - stdoutBuf := &strings.Builder{} - stderrBuf := &strings.Builder{} - stdCopyDone := make(chan struct{}) - defer close(stdCopyDone) - - go func() { - select { - case <-ctx.Done(): - _ = logs.Close() - case <-stdCopyDone: - } - }() - - _, err := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) - if err != nil && !errors.Is(err, io.EOF) { - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - return fmt.Errorf("failed to demultiplex logs: %w", err) - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - - // Send stdout lines - if stdoutBuf.Len() > 0 { - if err := s.readLogsFromReader(ctx, strings.NewReader(stdoutBuf.String()), logsChan, ""); err != nil { - return err - } - } - - // Send stderr lines with prefix - if stderrBuf.Len() > 0 { - if err := s.readLogsFromReader(ctx, strings.NewReader(stderrBuf.String()), logsChan, "[STDERR] "); err != nil { - return err - } - } - - return nil + return dockerutils.StreamContainerLogs(ctx, logs, logsChan, follow, isTTY) } func (s *ContainerService) ListContainersPaginated( @@ -1118,7 +1042,7 @@ func getContainerProjectNameInternal(container containertypes.Summary) string { return containerNoProjectGroup } - projectName := strings.TrimSpace(container.Labels["com.docker.compose.project"]) + projectName := dockerutils.ComposeProjectLabel(container.Labels) if projectName == "" { return containerNoProjectGroup } @@ -1373,7 +1297,7 @@ func (s *ContainerService) buildContainerFilterAccessors() []pagination.FilterAc { Key: "standalone", Fn: func(c containertypes.Summary, filterValue string) bool { - isStandalone := strings.TrimSpace(c.Labels["com.docker.compose.project"]) == "" + isStandalone := dockerutils.ComposeProjectLabel(c.Labels) == "" switch filterValue { case "true", "1": return isStandalone diff --git a/backend/internal/services/container_service_test.go b/backend/internal/services/container_service_test.go index f3c3e16ef7..73ccfbfccc 100644 --- a/backend/internal/services/container_service_test.go +++ b/backend/internal/services/container_service_test.go @@ -1,14 +1,8 @@ package services import ( - "bytes" - "context" - "encoding/binary" - "io" "net/netip" - "strings" "testing" - "time" "github.com/getarcaneapp/arcane/backend/pkg/pagination" containertypes "github.com/getarcaneapp/arcane/types/container" @@ -46,7 +40,7 @@ func TestPaginateContainerProjectGroupsKeepsProjectWhole(t *testing.T) { groupedItems, resp := paginateContainerProjectGroupsInternal( pagination.FilterResult[containertypes.Summary]{Items: items, TotalCount: int64(len(items)), TotalAvailable: int64(len(items))}, - pagination.QueryParams{PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}}, + pagination.QueryParams{Params: pagination.Params{Start: 0, Limit: 20}}, ) require.Len(t, groupedItems, 19) @@ -121,86 +115,6 @@ func TestBuildCleanNetworkingConfigInternalPreservesEndpointSettings(t *testing. require.Nil(t, out.EndpointsConfig["bridge"].IPAMConfig) } -func TestStreamContainerLogs_NonTTYFollowDemultiplexesStdoutAndStderr(t *testing.T) { - var stream bytes.Buffer - writeDockerLogFrameInternal(t, &stream, 1, "stdout line\n") - writeDockerLogFrameInternal(t, &stream, 2, "stderr line\n") - - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, true, false) - require.NoError(t, err) - - require.ElementsMatch(t, []string{"stdout line", "[STDERR] stderr line"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_TTYFollowStreamsRawOutput(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(strings.NewReader("first line\nsecond line")), logsChan, true, true) - require.NoError(t, err) - - require.Equal(t, []string{"first line", "second line"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_NonTTYSnapshotDemultiplexesStdoutAndStderr(t *testing.T) { - var stream bytes.Buffer - writeDockerLogFrameInternal(t, &stream, 1, "stdout snapshot\n") - writeDockerLogFrameInternal(t, &stream, 2, "stderr snapshot\n") - - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, false, false) - require.NoError(t, err) - - require.Equal(t, []string{"stdout snapshot", "[STDERR] stderr snapshot"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_TTYSnapshotStreamsRawOutput(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal(t.Context(), io.NopCloser(strings.NewReader("snapshot line\ntrailing line")), logsChan, false, true) - require.NoError(t, err) - - require.Equal(t, []string{"snapshot line", "trailing line"}, drainLogLinesInternal(logsChan)) -} - -func TestReadLogsFromReader_HandlesLongLinesAndPartialEOF(t *testing.T) { - longLine := strings.Repeat("a", 70*1024) - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.readLogsFromReader(t.Context(), strings.NewReader(longLine+"\npartial tail"), logsChan, "") - require.NoError(t, err) - - require.Equal(t, []string{longLine, "partial tail"}, drainLogLinesInternal(logsChan)) -} - -func TestStreamContainerLogs_TTYPythonLikeFollowDoesNotReturnEmptyLogs(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 4) - - err := service.streamContainerLogsInternal( - t.Context(), - io.NopCloser(strings.NewReader("2026-03-22 10:15:00 - INFO - Starting miner\n2026-03-22 10:15:01 - INFO - Connected")), - logsChan, - true, - true, - ) - require.NoError(t, err) - - lines := drainLogLinesInternal(logsChan) - require.NotEmpty(t, lines) - require.Equal(t, []string{ - "2026-03-22 10:15:00 - INFO - Starting miner", - "2026-03-22 10:15:01 - INFO - Connected", - }, lines) -} - func TestCompareContainerPortsForSortDesc_KeepsContainersWithoutPortsLast(t *testing.T) { withPublished := containertypes.Summary{ ID: "published", @@ -222,69 +136,6 @@ func TestCompareContainerPortsForSortDesc_KeepsContainersWithoutPortsLast(t *tes require.Equal(t, 1, compareContainerPortsForSortDescInternal(withoutPorts, withPublished)) } -func TestStreamMultiplexedLogs_ContextCancelDoesNotDeadlock(t *testing.T) { - var stream bytes.Buffer - writeDockerLogFrameInternal(t, &stream, 1, "line 1\n") - writeDockerLogFrameInternal(t, &stream, 1, "line 2\n") - writeDockerLogFrameInternal(t, &stream, 1, "line 3\n") - - logsChan := make(chan string, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - done := make(chan error, 1) - go func() { - done <- streamMultiplexedLogs(ctx, io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan) - }() - - require.Eventually(t, func() bool { - return len(logsChan) == 1 - }, time.Second, 10*time.Millisecond) - - cancel() - - select { - case err := <-done: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("streamMultiplexedLogs did not exit after cancellation") - } -} - -func TestReadAllLogs_ContextCancelClosesReader(t *testing.T) { - service := &ContainerService{} - logsChan := make(chan string, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - reader := &blockingReadCloser{readStarted: make(chan struct{}), closeCalled: make(chan struct{})} - done := make(chan error, 1) - go func() { - done <- service.readAllLogs(ctx, reader, logsChan) - }() - - select { - case <-reader.readStarted: - case <-time.After(time.Second): - t.Fatal("readAllLogs did not start reading") - } - - cancel() - - select { - case err := <-done: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(time.Second): - t.Fatal("readAllLogs did not exit after cancellation") - } - - select { - case <-reader.closeCalled: - case <-time.After(time.Second): - t.Fatal("readAllLogs did not close the reader on cancellation") - } -} - func newGroupedContainerSummary(name string, project string) containertypes.Summary { labels := map[string]string{} if project != "" { @@ -298,52 +149,3 @@ func newGroupedContainerSummary(name string, project string) containertypes.Summ State: "running", } } - -func drainLogLinesInternal(logsChan chan string) []string { - close(logsChan) - - lines := make([]string, 0, len(logsChan)) - for line := range logsChan { - lines = append(lines, line) - } - - return lines -} - -func writeDockerLogFrameInternal(t *testing.T, buffer *bytes.Buffer, streamType byte, payload string) { - t.Helper() - - header := make([]byte, 8) - header[0] = streamType - binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) - - _, err := buffer.Write(header) - require.NoError(t, err) - _, err = buffer.WriteString(payload) - require.NoError(t, err) -} - -type blockingReadCloser struct { - readStarted chan struct{} - closeCalled chan struct{} -} - -func (r *blockingReadCloser) Read(_ []byte) (int, error) { - select { - case <-r.readStarted: - default: - close(r.readStarted) - } - - <-r.closeCalled - return 0, io.EOF -} - -func (r *blockingReadCloser) Close() error { - select { - case <-r.closeCalled: - default: - close(r.closeCalled) - } - return nil -} diff --git a/backend/internal/services/dashboard_service.go b/backend/internal/services/dashboard_service.go index f4bc08de53..33d15305e6 100644 --- a/backend/internal/services/dashboard_service.go +++ b/backend/internal/services/dashboard_service.go @@ -2,22 +2,18 @@ package services import ( "context" + "errors" "fmt" - "log/slog" - "net/http" "sort" - "strings" "time" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" - "github.com/getarcaneapp/arcane/backend/pkg/libarcane" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/types/base" containertypes "github.com/getarcaneapp/arcane/types/container" dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" - environmenttypes "github.com/getarcaneapp/arcane/types/environment" imagetypes "github.com/getarcaneapp/arcane/types/image" versiontypes "github.com/getarcaneapp/arcane/types/version" dockercontainer "github.com/moby/moby/api/types/container" @@ -26,8 +22,6 @@ import ( const defaultDashboardAPIKeyExpiryWindow = 14 * 24 * time.Hour const dashboardSnapshotPreloadLimit = 50 -const defaultAggregateDashboardConcurrency = 4 -const defaultAggregateDashboardTimeout = 20 * time.Second const localEnvironmentID = "0" type DashboardService struct { @@ -72,7 +66,7 @@ func NewDashboardService( func (s *DashboardService) GetSnapshot(ctx context.Context, options DashboardActionItemsOptions) (*dashboardtypes.Snapshot, error) { if s.dockerService == nil { - return nil, fmt.Errorf("docker service not available") + return nil, errors.New("docker service not available") } dockerSnapshot, err := s.dockerService.GetSnapshot(ctx, localEnvironmentID) @@ -143,6 +137,11 @@ func (s *DashboardService) GetSnapshot(ctx context.Context, options DashboardAct return nil, err } + var versionInfo *versiontypes.Info + if s.versionService != nil { + versionInfo = s.versionService.GetAppVersionInfo(ctx) + } + return &dashboardtypes.Snapshot{ Containers: dashboardtypes.SnapshotContainers{ Data: containerPage, @@ -156,150 +155,10 @@ func (s *DashboardService) GetSnapshot(ctx context.Context, options DashboardAct ImageUsageCounts: imageUsageCounts, ActionItems: *actionItems, Settings: dashboardtypes.SnapshotSettings{}, + VersionInfo: versionInfo, }, nil } -func (s *DashboardService) GetActionItems(ctx context.Context, options DashboardActionItemsOptions) (*dashboardtypes.ActionItems, error) { - if options.DebugAllGood { - return &dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}}, nil - } - - var ( - stoppedContainers int - pendingResourceUpdates int - actionableVulnerabilities int - expiringAPIKeys int - ) - - g, groupCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - count, err := s.getStoppedContainersCountInternal(groupCtx) - if err != nil { - return err - } - stoppedContainers = count - return nil - }) - - g.Go(func() error { - count, err := s.getPendingResourceUpdatesCountInternal(groupCtx) - if err != nil { - return err - } - pendingResourceUpdates = count - return nil - }) - - g.Go(func() error { - count, err := s.getActionableVulnerabilitiesCountInternal(groupCtx) - if err != nil { - return err - } - actionableVulnerabilities = count - return nil - }) - - g.Go(func() error { - count, err := s.getExpiringAPIKeysCountInternal(groupCtx) - if err != nil { - return err - } - expiringAPIKeys = count - return nil - }) - - if err := g.Wait(); err != nil { - return nil, err - } - - actionItems := make([]dashboardtypes.ActionItem, 0, 4) - - if stoppedContainers > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindStoppedContainers, - Count: stoppedContainers, - Severity: dashboardtypes.ActionItemSeverityWarning, - }) - } - - if pendingResourceUpdates > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindImageUpdates, - Count: pendingResourceUpdates, - Severity: dashboardtypes.ActionItemSeverityWarning, - }) - } - - if actionableVulnerabilities > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindActionableVulnerabilities, - Count: actionableVulnerabilities, - Severity: dashboardtypes.ActionItemSeverityCritical, - }) - } - - if expiringAPIKeys > 0 { - actionItems = append(actionItems, dashboardtypes.ActionItem{ - Kind: dashboardtypes.ActionItemKindExpiringKeys, - Count: expiringAPIKeys, - Severity: dashboardtypes.ActionItemSeverityWarning, - }) - } - - return &dashboardtypes.ActionItems{Items: actionItems}, nil -} - -func (s *DashboardService) GetEnvironmentsOverview( - ctx context.Context, - options DashboardActionItemsOptions, -) (*dashboardtypes.EnvironmentsOverview, error) { - if s.environmentService == nil { - return nil, fmt.Errorf("environment service not available") - } - - environments, err := s.environmentService.ListVisibleEnvironments(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list environments: %w", err) - } - - overview := &dashboardtypes.EnvironmentsOverview{ - Environments: make([]dashboardtypes.EnvironmentOverview, len(environments)), - } - - g, groupCtx := errgroup.WithContext(ctx) - g.SetLimit(defaultAggregateDashboardConcurrency) - - for i := range environments { - index := i - env := environments[i] - - g.Go(func() error { - overview.Environments[index] = s.buildEnvironmentOverviewInternal(groupCtx, env, options) - return nil - }) - } - - if err := g.Wait(); err != nil { - return nil, fmt.Errorf("failed to build environments overview: %w", err) - } - - sort.SliceStable(overview.Environments, func(i, j int) bool { - left := overview.Environments[i].Environment - right := overview.Environments[j].Environment - if left.ID == localEnvironmentID { - return true - } - if right.ID == localEnvironmentID { - return false - } - return left.Name < right.Name - }) - - overview.Summary = summarizeEnvironmentOverviewInternal(overview.Environments) - return overview, nil -} - func (s *DashboardService) buildActionItemsForSnapshotInternal( ctx context.Context, options DashboardActionItemsOptions, @@ -402,210 +261,6 @@ func buildDashboardActionItemsInternal( return &dashboardtypes.ActionItems{Items: actionItems} } -func (s *DashboardService) buildEnvironmentOverviewInternal( - ctx context.Context, - env environmenttypes.Environment, - options DashboardActionItemsOptions, -) dashboardtypes.EnvironmentOverview { - overview := dashboardtypes.EnvironmentOverview{ - Environment: env, - Containers: containertypes.StatusCounts{}, - ImageUsageCounts: imagetypes.UsageCounts{}, - ActionItems: dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}}, - Settings: dashboardtypes.SnapshotSettings{}, - SnapshotState: dashboardtypes.EnvironmentSnapshotStateSkipped, - } - - if !env.Enabled || !shouldFetchEnvironmentSnapshotInternal(env) { - return overview - } - - var ( - snapshot *dashboardtypes.Snapshot - snapshotErr error - versionInfo *versiontypes.Info - ) - - g, groupCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - snapshot, snapshotErr = s.getSnapshotForEnvironmentInternal(groupCtx, env, options) - return nil - }) - - g.Go(func() error { - versionInfo = s.getVersionInfoForEnvironmentInternal(groupCtx, env) - return nil - }) - - _ = g.Wait() - - if versionInfo != nil { - overview.VersionInfo = versionInfo - } - - if snapshotErr != nil { - message := snapshotErr.Error() - overview.SnapshotState = dashboardtypes.EnvironmentSnapshotStateError - overview.SnapshotError = &message - return overview - } - - overview.Containers = snapshot.Containers.Counts - overview.ImageUsageCounts = snapshot.ImageUsageCounts - overview.ActionItems = snapshot.ActionItems - overview.Settings = snapshot.Settings - overview.SnapshotState = dashboardtypes.EnvironmentSnapshotStateReady - - return overview -} - -func shouldFetchEnvironmentSnapshotInternal(env environmenttypes.Environment) bool { - switch env.Status { - case string(models.EnvironmentStatusOnline), string(models.EnvironmentStatusStandby): - return true - default: - return false - } -} - -func (s *DashboardService) getSnapshotForEnvironmentInternal( - ctx context.Context, - env environmenttypes.Environment, - options DashboardActionItemsOptions, -) (*dashboardtypes.Snapshot, error) { - reqCtx, cancel := context.WithTimeout(ctx, defaultAggregateDashboardTimeout) - defer cancel() - - if env.ID == localEnvironmentID { - return s.GetSnapshot(reqCtx, options) - } - - var response base.ApiResponse[dashboardtypes.Snapshot] - if err := s.environmentService.ProxyJSONRequest( - reqCtx, - env.ID, - http.MethodGet, - buildEnvironmentDashboardProxyPathInternal(options), - nil, - &response, - ); err != nil { - return nil, fmt.Errorf("failed to proxy dashboard snapshot: %w", err) - } - if !response.Success { - return nil, fmt.Errorf("dashboard snapshot request was not successful") - } - - return &response.Data, nil -} - -func buildEnvironmentDashboardProxyPathInternal(options DashboardActionItemsOptions) string { - if options.DebugAllGood { - return fmt.Sprintf("/api/environments/%s/dashboard?debugAllGood=true", localEnvironmentID) - } - - return fmt.Sprintf("/api/environments/%s/dashboard", localEnvironmentID) -} - -func (s *DashboardService) getVersionInfoForEnvironmentInternal( - ctx context.Context, - env environmenttypes.Environment, -) *versiontypes.Info { - if s.versionService == nil { - return nil - } - - reqCtx, cancel := context.WithTimeout(ctx, defaultAggregateDashboardTimeout) - defer cancel() - - if env.ID == localEnvironmentID { - return s.versionService.GetAppVersionInfo(reqCtx) - } - - if s.environmentService == nil { - return nil - } - - var versionInfo versiontypes.Info - if err := s.environmentService.ProxyJSONRequest( - reqCtx, - env.ID, - http.MethodGet, - "/api/app-version", - nil, - &versionInfo, - ); err != nil { - slog.DebugContext(reqCtx, "Failed to fetch environment version info for dashboard", "environment_id", env.ID, "error", err) - return nil - } - - return &versionInfo -} - -func (s *DashboardService) getStoppedContainersCountInternal(ctx context.Context) (int, error) { - if s.dockerService == nil { - return 0, nil - } - - containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) - if err != nil { - return 0, fmt.Errorf("failed to load container counts: %w", err) - } - - stoppedCount := 0 - for _, container := range containers { - if libarcane.IsInternalContainer(container.Labels) { - continue - } - - if container.State != "running" { - stoppedCount++ - } - } - - return stoppedCount, nil -} - -func summarizeEnvironmentOverviewInternal(items []dashboardtypes.EnvironmentOverview) dashboardtypes.EnvironmentsSummary { - summary := dashboardtypes.EnvironmentsSummary{} - - for _, item := range items { - summary.TotalEnvironments++ - - if !item.Environment.Enabled { - summary.DisabledEnvironments++ - } else { - switch item.Environment.Status { - case string(models.EnvironmentStatusOnline): - summary.OnlineEnvironments++ - case string(models.EnvironmentStatusStandby): - summary.StandbyEnvironments++ - case string(models.EnvironmentStatusPending): - summary.PendingEnvironments++ - case string(models.EnvironmentStatusError): - summary.ErrorEnvironments++ - default: - summary.OfflineEnvironments++ - } - } - - summary.Containers.RunningContainers += item.Containers.RunningContainers - summary.Containers.StoppedContainers += item.Containers.StoppedContainers - summary.Containers.TotalContainers += item.Containers.TotalContainers - - summary.ImageUsageCounts.Inuse += item.ImageUsageCounts.Inuse - summary.ImageUsageCounts.Unused += item.ImageUsageCounts.Unused - summary.ImageUsageCounts.Total += item.ImageUsageCounts.Total - summary.ImageUsageCounts.TotalSize += item.ImageUsageCounts.TotalSize - - if len(item.ActionItems.Items) > 0 { - summary.EnvironmentsWithActionItems++ - } - } - - return summary -} - func (s *DashboardService) getPendingResourceUpdatesCountInternal(ctx context.Context) (int, error) { if s.db == nil || s.dockerService == nil { return 0, nil @@ -634,7 +289,7 @@ func (s *DashboardService) getPendingResourceUpdatesCountInternal(ctx context.Co func filterStandaloneDockerContainersInternal(containers []dockercontainer.Summary) []dockercontainer.Summary { filtered := make([]dockercontainer.Summary, 0, len(containers)) for _, c := range containers { - if strings.TrimSpace(c.Labels["com.docker.compose.project"]) != "" { + if dockerutils.ComposeProjectLabel(c.Labels) != "" { continue } filtered = append(filtered, c) diff --git a/backend/internal/services/dashboard_service_test.go b/backend/internal/services/dashboard_service_test.go index d78720b086..6a048bbaee 100644 --- a/backend/internal/services/dashboard_service_test.go +++ b/backend/internal/services/dashboard_service_test.go @@ -14,16 +14,11 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" - "github.com/getarcaneapp/arcane/types/base" - containertypes "github.com/getarcaneapp/arcane/types/container" dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" - imagetypes "github.com/getarcaneapp/arcane/types/image" - versiontypes "github.com/getarcaneapp/arcane/types/version" glsqlite "github.com/glebarez/sqlite" dockercontainer "github.com/moby/moby/api/types/container" dockerimage "github.com/moby/moby/api/types/image" "github.com/moby/moby/client" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) @@ -52,11 +47,6 @@ func createDashboardTestImageUpdateRecord(t *testing.T, db *database.DB, record require.NoError(t, db.WithContext(context.Background()).Create(&record).Error) } -func createDashboardTestEnvironment(t *testing.T, db *database.DB, env models.Environment) { - t.Helper() - require.NoError(t, db.WithContext(context.Background()).Create(&env).Error) -} - func newDashboardTestDockerService( t *testing.T, settingsSvc *SettingsService, @@ -94,74 +84,6 @@ func newDashboardTestDockerService( } } -func newDashboardTestVersionServiceInternal() *VersionService { - return NewVersionService(nil, true, "1.2.3", "abcdef1234567890", nil, nil) -} - -func TestDashboardService_GetActionItems_IncludesExpiringAPIKeys(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, nil, nil) - - now := time.Now() - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "expiring-soon", - KeyHash: "hash-soon", - KeyPrefix: "arc_test_s", - UserID: new("user-1"), - ExpiresAt: new(now.Add(24 * time.Hour)), - }) - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "already-expired", - KeyHash: "hash-expired", - KeyPrefix: "arc_test_e", - UserID: new("user-1"), - ExpiresAt: new(now.Add(-24 * time.Hour)), - }) - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "future", - KeyHash: "hash-future", - KeyPrefix: "arc_test_f", - UserID: new("user-1"), - ExpiresAt: new(now.Add(45 * 24 * time.Hour)), - }) - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "never-expires", - KeyHash: "hash-never", - KeyPrefix: "arc_test_n", - UserID: new("user-1"), - }) - - actionItems, err := svc.GetActionItems(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, actionItems) - require.Len(t, actionItems.Items, 1) - - item := actionItems.Items[0] - require.Equal(t, dashboardtypes.ActionItemKindExpiringKeys, item.Kind) - require.Equal(t, 2, item.Count) - require.Equal(t, dashboardtypes.ActionItemSeverityWarning, item.Severity) -} - -func TestDashboardService_GetActionItems_DebugAllGoodReturnsNoItems(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, nil, nil) - - createDashboardTestAPIKey(t, db, models.ApiKey{ - Name: "expiring-soon", - KeyHash: "hash-soon", - KeyPrefix: "arc_test_d", - UserID: new("user-1"), - ExpiresAt: new(time.Now().Add(2 * time.Hour)), - }) - - actionItems, err := svc.GetActionItems(context.Background(), DashboardActionItemsOptions{ - DebugAllGood: true, - }) - require.NoError(t, err) - require.NotNil(t, actionItems) - require.Empty(t, actionItems.Items) -} - func TestDashboardService_GetSnapshot_ReturnsDashboardSnapshot(t *testing.T) { db, settingsSvc := setupDashboardServiceTestDB(t) @@ -233,7 +155,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{}) @@ -296,289 +218,3 @@ func TestDashboardService_GetSnapshot_DebugAllGoodOnlyClearsActionItems(t *testi require.Equal(t, 1, snapshot.ImageUsageCounts.Inuse) require.Empty(t, snapshot.ActionItems.Items) } - -func TestDashboardService_GetEnvironmentsOverview_ReturnsLocalAndRemoteSummaries(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - - containers := []dockercontainer.Summary{ - { - ID: "local-running", - Names: []string{"/local-running"}, - Image: "repo/local:stable", - ImageID: "sha256:local-image", - Created: 1700000000, - State: "running", - Status: "Up 1 hour", - Labels: map[string]string{}, - }, - } - images := []dockerimage.Summary{ - {ID: "sha256:local-image", RepoTags: []string{"repo/local:stable"}, Created: 1710000000, Size: 150}, - } - - remoteSnapshot := base.ApiResponse[dashboardtypes.Snapshot]{ - Success: true, - Data: dashboardtypes.Snapshot{ - Containers: dashboardtypes.SnapshotContainers{ - Counts: containertypes.StatusCounts{ - RunningContainers: 2, - StoppedContainers: 1, - TotalContainers: 3, - }, - }, - ImageUsageCounts: imagetypes.UsageCounts{ - Inuse: 2, - Unused: 3, - Total: 5, - TotalSize: 900, - }, - ActionItems: dashboardtypes.ActionItems{ - Items: []dashboardtypes.ActionItem{ - {Kind: dashboardtypes.ActionItemKindStoppedContainers, Count: 1, Severity: dashboardtypes.ActionItemSeverityWarning}, - }, - }, - }, - } - - remoteVersion := versiontypes.Info{ - CurrentVersion: "v2.4.0", - DisplayVersion: "v2.4.0", - Revision: "1234567890abcdef", - ShortRevision: "12345678", - GoVersion: "go1.24.0", - IsSemverVersion: true, - UpdateAvailable: true, - NewestVersion: "v2.5.0", - ReleaseURL: "https://github.com/getarcaneapp/arcane/releases/tag/v2.5.0", - } - - remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/environments/0/dashboard": - require.NoError(t, json.NewEncoder(w).Encode(remoteSnapshot)) - case "/api/app-version": - require.NoError(t, json.NewEncoder(w).Encode(remoteVersion)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(remoteServer.Close) - - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "0", CreatedAt: time.Now()}, - Name: "Local Docker", - ApiUrl: "http://local.test", - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-remote", CreatedAt: time.Now()}, - Name: "Remote Alpha", - ApiUrl: remoteServer.URL, - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - - dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images) - envSvc := NewEnvironmentService(db, remoteServer.Client(), nil, nil, settingsSvc, nil) - svc := NewDashboardService(db, dockerSvc, nil, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal()) - - overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, overview) - require.Len(t, overview.Environments, 2) - - require.Equal(t, 2, overview.Summary.TotalEnvironments) - require.Equal(t, 2, overview.Summary.OnlineEnvironments) - require.Equal(t, 4, overview.Summary.Containers.TotalContainers) - require.Equal(t, 6, overview.Summary.ImageUsageCounts.Total) - require.Equal(t, 1, overview.Summary.EnvironmentsWithActionItems) - - require.Equal(t, "0", overview.Environments[0].Environment.ID) - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[0].SnapshotState) - require.Equal(t, 1, overview.Environments[0].Containers.TotalContainers) - require.NotNil(t, overview.Environments[0].VersionInfo) - require.Equal(t, "v1.2.3", overview.Environments[0].VersionInfo.CurrentVersion) - - require.Equal(t, "env-remote", overview.Environments[1].Environment.ID) - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[1].SnapshotState) - require.Equal(t, 3, overview.Environments[1].Containers.TotalContainers) - require.Len(t, overview.Environments[1].ActionItems.Items, 1) - require.NotNil(t, overview.Environments[1].VersionInfo) - require.Equal(t, "v2.5.0", overview.Environments[1].VersionInfo.NewestVersion) -} - -func TestDashboardService_GetEnvironmentsOverview_HandlesRemoteSnapshotFailure(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-offline", CreatedAt: time.Now()}, - Name: "Offline Env", - ApiUrl: "http://offline.test", - Status: string(models.EnvironmentStatusOffline), - Enabled: true, - }) - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-error", CreatedAt: time.Now()}, - Name: "Broken Env", - ApiUrl: "http://127.0.0.1:1", - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - - envSvc := NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal()) - - overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, overview) - require.Len(t, overview.Environments, 2) - - byID := make(map[string]dashboardtypes.EnvironmentOverview, len(overview.Environments)) - for _, item := range overview.Environments { - byID[item.Environment.ID] = item - } - - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateSkipped, byID["env-offline"].SnapshotState) - require.Nil(t, byID["env-offline"].SnapshotError) - require.Nil(t, byID["env-offline"].VersionInfo) - - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateError, byID["env-error"].SnapshotState) - require.NotNil(t, byID["env-error"].SnapshotError) - require.Contains(t, *byID["env-error"].SnapshotError, "failed to proxy dashboard snapshot") - require.Nil(t, byID["env-error"].VersionInfo) -} - -func TestDashboardService_GetEnvironmentsOverview_OmitsVersionInfoWhenFetchFails(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - - remoteSnapshot := base.ApiResponse[dashboardtypes.Snapshot]{ - Success: true, - Data: dashboardtypes.Snapshot{ - Containers: dashboardtypes.SnapshotContainers{ - Counts: containertypes.StatusCounts{ - RunningContainers: 1, - StoppedContainers: 0, - TotalContainers: 1, - }, - }, - ImageUsageCounts: imagetypes.UsageCounts{ - Inuse: 1, - Unused: 0, - Total: 1, - TotalSize: 128, - }, - ActionItems: dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}}, - }, - } - - remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/environments/0/dashboard": - require.NoError(t, json.NewEncoder(w).Encode(remoteSnapshot)) - case "/api/app-version": - http.Error(w, "version unavailable", http.StatusInternalServerError) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(remoteServer.Close) - - createDashboardTestEnvironment(t, db, models.Environment{ - BaseModel: models.BaseModel{ID: "env-remote", CreatedAt: time.Now()}, - Name: "Remote Alpha", - ApiUrl: remoteServer.URL, - Status: string(models.EnvironmentStatusOnline), - Enabled: true, - }) - - envSvc := NewEnvironmentService(db, remoteServer.Client(), nil, nil, settingsSvc, nil) - svc := NewDashboardService(db, nil, nil, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal()) - - overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, overview) - require.Len(t, overview.Environments, 1) - - require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[0].SnapshotState) - require.Equal(t, 1, overview.Environments[0].Containers.TotalContainers) - require.Nil(t, overview.Environments[0].VersionInfo) -} - -func TestDashboardService_GetActionItems_CountsAffectedResources(t *testing.T) { - db, settingsSvc := setupDashboardServiceTestDB(t) - ctx := context.Background() - - containers := []dockercontainer.Summary{ - { - ID: "container-updated", - Names: []string{"/updated-app"}, - Image: "repo/app:latest", - ImageID: "sha256:image-a", - Created: 1700000000, - State: "running", - Status: "Up 2 hours", - Labels: map[string]string{}, - }, - { - ID: "compose-updated", - Names: []string{"/compose-app"}, - Image: "repo/compose:latest", - ImageID: "sha256:image-compose", - Created: 1700000001, - State: "running", - Status: "Up 2 hours", - Labels: map[string]string{ - "com.docker.compose.project": "compose-demo", - }, - }, - } - images := []dockerimage.Summary{ - {ID: "sha256:image-a", RepoTags: []string{"repo/app:latest"}, Created: 1710000000, Size: 100}, - {ID: "sha256:image-unused", RepoTags: []string{"repo/unused:latest"}, Created: 1710000001, Size: 50}, - } - - createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ - ID: "sha256:image-a", - Repository: "docker.io/repo/app", - Tag: "latest", - HasUpdate: true, - }) - createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ - ID: "sha256:image-unused", - Repository: "docker.io/repo/unused", - Tag: "latest", - HasUpdate: true, - }) - createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ - ID: "sha256:image-compose", - Repository: "docker.io/repo/compose", - Tag: "latest", - HasUpdate: true, - }) - - projectsDir := t.TempDir() - t.Setenv("PROJECTS_DIRECTORY", projectsDir) - require.NoError(t, settingsSvc.SetStringSetting(ctx, "projectsDirectory", projectsDir)) - projectPath := createComposeProjectDir(t, projectsDir, "project-with-update") - require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: repo/app:latest\n"), 0o644)) - require.NoError(t, db.WithContext(ctx).Create(&models.Project{ - BaseModel: models.BaseModel{ID: "project-with-update"}, - Name: "project-with-update", - DirName: ptr("project-with-update"), - Path: projectPath, - Status: models.ProjectStatusStopped, - }).Error) - - dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images) - projectSvc := NewProjectService(db, settingsSvc, nil, &ImageService{db: db}, nil, nil, config.Load()) - svc := NewDashboardService(db, dockerSvc, nil, projectSvc, nil, settingsSvc, nil, nil, nil) - - actionItems, err := svc.GetActionItems(ctx, DashboardActionItemsOptions{}) - require.NoError(t, err) - require.NotNil(t, actionItems) - - require.Len(t, actionItems.Items, 1) - assert.Equal(t, dashboardtypes.ActionItemKindImageUpdates, actionItems.Items[0].Kind) - assert.Equal(t, 2, actionItems.Items[0].Count) -} diff --git a/backend/internal/services/diagnostics_service.go b/backend/internal/services/diagnostics_service.go new file mode 100644 index 0000000000..63a784f084 --- /dev/null +++ b/backend/internal/services/diagnostics_service.go @@ -0,0 +1,85 @@ +package services + +import ( + "runtime" + "runtime/debug" + "time" + + "github.com/getarcaneapp/arcane/types/system" +) + +// recentGCPauseSamples is the number of recent GC pause durations reported. +const recentGCPauseSamples = 16 + +// DiagnosticsService gathers Go runtime, memory, and garbage-collector +// statistics for the diagnostics endpoints. It holds no external dependencies; +// WebSocket metrics and worker-goroutine counts are merged in at the handler +// layer to avoid an import cycle with the api/ws package. +type DiagnosticsService struct { + startedAt time.Time +} + +// NewDiagnosticsService returns a DiagnosticsService. startedAt is captured at +// construction (≈ process start) and used to report uptime. +func NewDiagnosticsService() *DiagnosticsService { + return &DiagnosticsService{startedAt: time.Now()} +} + +// Collect samples the current runtime, memory, and GC state. +func (s *DiagnosticsService) Collect() (system.RuntimeInfo, system.MemoryInfo, system.GCInfo) { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + var gc debug.GCStats + debug.ReadGCStats(&gc) + + rt := system.RuntimeInfo{ + Goroutines: runtime.NumGoroutine(), + GOMAXPROCS: runtime.GOMAXPROCS(0), + NumCPU: runtime.NumCPU(), + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + NumCgoCall: runtime.NumCgoCall(), + UptimeSeconds: int64(time.Since(s.startedAt).Seconds()), + } + + mi := system.MemoryInfo{ + Alloc: mem.Alloc, + TotalAlloc: mem.TotalAlloc, + Sys: mem.Sys, + HeapAlloc: mem.HeapAlloc, + HeapSys: mem.HeapSys, + HeapInuse: mem.HeapInuse, + HeapIdle: mem.HeapIdle, + HeapReleased: mem.HeapReleased, + HeapObjects: mem.HeapObjects, + StackInuse: mem.StackInuse, + StackSys: mem.StackSys, + MSpanInuse: mem.MSpanInuse, + MCacheInuse: mem.MCacheInuse, + NextGC: mem.NextGC, + NumGC: mem.NumGC, + NumForcedGC: mem.NumForcedGC, + GCCPUFraction: mem.GCCPUFraction, + } + + // gc.Pause is ordered most-recent-first; cap the slice we expose. + pauses := gc.Pause + if len(pauses) > recentGCPauseSamples { + pauses = pauses[:recentGCPauseSamples] + } + recent := make([]int64, len(pauses)) + for i, p := range pauses { + recent[i] = p.Nanoseconds() + } + + gi := system.GCInfo{ + LastGC: gc.LastGC, + NumGC: gc.NumGC, + PauseTotalNs: gc.PauseTotal.Nanoseconds(), + RecentPausesNs: recent, + } + + return rt, mi, gi +} diff --git a/backend/internal/services/diagnostics_service_test.go b/backend/internal/services/diagnostics_service_test.go new file mode 100644 index 0000000000..3708702091 --- /dev/null +++ b/backend/internal/services/diagnostics_service_test.go @@ -0,0 +1,22 @@ +package services + +import "testing" + +func TestDiagnosticsServiceCollect(t *testing.T) { + s := NewDiagnosticsService() + + rt, mem, _ := s.Collect() + + if rt.Goroutines <= 0 { + t.Errorf("expected a positive goroutine count, got %d", rt.Goroutines) + } + if rt.GoVersion == "" { + t.Error("expected a non-empty Go version") + } + if rt.NumCPU <= 0 { + t.Errorf("expected a positive CPU count, got %d", rt.NumCPU) + } + if mem.Sys == 0 { + t.Error("expected non-zero Sys memory") + } +} diff --git a/backend/internal/services/docker_client_service.go b/backend/internal/services/docker_client_service.go index a12ea22496..d7973288f3 100644 --- a/backend/internal/services/docker_client_service.go +++ b/backend/internal/services/docker_client_service.go @@ -10,7 +10,7 @@ import ( "sync" "time" - backoff "github.com/cenkalti/backoff/v5" + "github.com/cenkalti/backoff/v5" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" @@ -557,7 +557,7 @@ func countImageUsageInternal(images []image.Summary, containers []container.Summ return inuse, unused, total } -func (s *DockerClientService) GetAllNetworks(ctx context.Context) (_ []network.Summary, inuseNetworks int, unusedNetworks int, totalNetworks int, error error) { +func (s *DockerClientService) GetAllNetworks(ctx context.Context) ([]network.Summary, int, int, int, error) { containers, err := s.listContainersInternal(ctx) if err != nil { return nil, 0, 0, 0, err diff --git a/backend/internal/services/ecr_token_service.go b/backend/internal/services/ecr_token_service.go index e37e95f463..8f6c1c0222 100644 --- a/backend/internal/services/ecr_token_service.go +++ b/backend/internal/services/ecr_token_service.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" ) @@ -48,7 +49,10 @@ func (s *ContainerRegistryService) GetOrRefreshECRToken(ctx context.Context, reg if sErr != nil { return "", "", sErr } - r := result.(*ecrTokenResult) + r, ok := result.(*ecrTokenResult) + if !ok { + return "", "", &common.ECRTokenResultTypeError{} + } return r.username, r.password, nil } diff --git a/backend/internal/services/environment_service.go b/backend/internal/services/environment_service.go index 312acf0d19..4993fec262 100644 --- a/backend/internal/services/environment_service.go +++ b/backend/internal/services/environment_service.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" "fmt" - "io" "log/slog" + "maps" "net/http" "strings" "sync" @@ -304,12 +304,12 @@ func (s *EnvironmentService) ListSwarmNodeAgentEnvironments(ctx context.Context, func buildSwarmNodeAgentNameInternal(hostname, nodeID string) string { trimmedHostname := strings.TrimSpace(hostname) if trimmedHostname != "" { - return fmt.Sprintf("Swarm Node Agent - %s", trimmedHostname) + return "Swarm Node Agent - " + trimmedHostname } if len(nodeID) > 12 { nodeID = nodeID[:12] } - return fmt.Sprintf("Swarm Node Agent - %s", nodeID) + return "Swarm Node Agent - " + nodeID } func buildSwarmNodeAgentURLInternal(nodeID string) string { @@ -327,7 +327,7 @@ func (s *EnvironmentService) applySwarmNodeAgentApiKeyInternal( rotate bool, ) (string, error) { if env == nil { - return "", fmt.Errorf("environment is required") + return "", errors.New("environment is required") } if !rotate && env.AccessToken != nil && strings.TrimSpace(*env.AccessToken) != "" { @@ -335,7 +335,7 @@ func (s *EnvironmentService) applySwarmNodeAgentApiKeyInternal( } if s.apiKeyService == nil { - return "", fmt.Errorf("api key service not configured") + return "", errors.New("api key service not configured") } apiKeyDto, err := s.apiKeyService.CreateEnvironmentApiKey(ctx, env.ID, userID) @@ -356,10 +356,10 @@ func (s *EnvironmentService) EnsureSwarmNodeAgentEnvironment( rotate bool, ) (*models.Environment, string, error) { if strings.TrimSpace(parentEnvironmentID) == "" { - return nil, "", fmt.Errorf("parent environment ID is required") + return nil, "", errors.New("parent environment ID is required") } if strings.TrimSpace(nodeID) == "" { - return nil, "", fmt.Errorf("swarm node ID is required") + return nil, "", errors.New("swarm node ID is required") } var env models.Environment @@ -448,7 +448,7 @@ func (s *EnvironmentService) GetEnvironmentByID(ctx context.Context, id string) var environment models.Environment if err := s.db.WithContext(ctx).Where("id = ?", id).First(&environment).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("environment not found") + return nil, errors.New("environment not found") } return nil, fmt.Errorf("failed to get environment: %w", err) } @@ -761,7 +761,7 @@ func (s *EnvironmentService) TestConnection(ctx context.Context, id string, cust } return "offline", fmt.Errorf("failed to create request: %w", err) } - resp, err := s.httpClient.Do(req) //nolint:gosec // intentional request to configured remote environment API URL + resp, err := s.httpClient.Do(req) if err != nil { if customApiUrl == nil { _ = s.updateEnvironmentStatusInternal(ctx, id, string(models.EnvironmentStatusOffline)) @@ -788,7 +788,7 @@ func (s *EnvironmentService) testEdgeConnection(ctx context.Context, id string) if !edge.HasActiveTunnel(id) { if _, ok := edge.RequestTunnelAndWait(ctx, id, edge.DefaultTunnelDemandTTL, edge.DefaultTunnelAcquireTimeout()); !ok { _ = s.updateEnvironmentStatusInternal(ctx, id, string(models.EnvironmentStatusOffline)) - return "offline", fmt.Errorf("edge agent is not connected") + return "offline", errors.New("edge agent is not connected") } } @@ -971,64 +971,6 @@ func (s *EnvironmentService) RegenerateEnvironmentApiKey(ctx context.Context, en return nil } -// Deprecated - Use the Api Key flow -func (s *EnvironmentService) PairAgentWithBootstrap(ctx context.Context, apiUrl, bootstrapToken string) (string, error) { - pairURL, err := buildEnvironmentEndpointURLInternal(apiUrl, "/api/environments/0/agent/pair") - if err != nil { - return "", fmt.Errorf("invalid agent API URL: %w", err) - } - - reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, pairURL, nil) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - req.Header.Set("X-Arcane-Agent-Bootstrap", bootstrapToken) - - resp, err := s.httpClient.Do(req) //nolint:gosec // intentional request to configured remote environment API URL - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) - } - - var parsed struct { - Success bool `json:"success"` - Data struct { - Token string `json:"token"` - } `json:"data"` - Message string `json:"message"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return "", fmt.Errorf("decode response: %w", err) - } - if !parsed.Success || parsed.Data.Token == "" { - return "", fmt.Errorf("pairing unsuccessful") - } - - return parsed.Data.Token, nil -} - -func (s *EnvironmentService) PairAndPersistAgentToken(ctx context.Context, environmentID, apiUrl, bootstrapToken string) (string, error) { - token, err := s.PairAgentWithBootstrap(ctx, apiUrl, bootstrapToken) - if err != nil { - return "", err - } - if err := s.db.WithContext(ctx). - Model(&models.Environment{}). - Where("id = ?", environmentID). - Update("access_token", token).Error; err != nil { - return "", fmt.Errorf("failed to persist agent token: %w", err) - } - s.syncEnvironmentTokenCacheInternal(environmentID, token) - return token, nil -} - func (s *EnvironmentService) GetDB() *database.DB { return s.db } @@ -1134,13 +1076,13 @@ func (s *EnvironmentService) GenerateDeploymentSnippets(ctx context.Context, env " environment:", " - AGENT_MODE=true", " - EDGE_TRANSPORT=poll", - fmt.Sprintf(" - AGENT_TOKEN=%s", apiKey), - fmt.Sprintf(" - MANAGER_API_URL=%s", managerURL), + " - AGENT_TOKEN=" + apiKey, + " - MANAGER_API_URL=" + managerURL, " ports:", " - \"3553:3553\"", " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", - fmt.Sprintf(" - arcane-data:%s", deploymentSnippetsDataPath), + " - arcane-data:" + deploymentSnippetsDataPath, "", "volumes:", " arcane-data:", @@ -1180,11 +1122,11 @@ func (s *EnvironmentService) GenerateEdgeDeploymentSnippets(ctx context.Context, " environment:", " - EDGE_AGENT=true", " - EDGE_TRANSPORT=poll", - fmt.Sprintf(" - AGENT_TOKEN=%s", apiKey), - fmt.Sprintf(" - MANAGER_API_URL=%s", managerURL), + " - AGENT_TOKEN=" + apiKey, + " - MANAGER_API_URL=" + managerURL, " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", - fmt.Sprintf(" - arcane-data:%s", deploymentSnippetsDataPath), + " - arcane-data:" + deploymentSnippetsDataPath, "", "volumes:", " arcane-data:", @@ -1235,17 +1177,15 @@ func (s *EnvironmentService) logGeneratedMTLSEventsInternal(ctx context.Context, } } if assets.CertIssued { - resourceType := "environment" envIDCopy := envID - envNameCopy := envName if _, err := s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeEnvironmentMTLSCertIssued, Severity: models.EventSeverityInfo, Title: "Edge mTLS certificate issued", Description: fmt.Sprintf("Arcane issued an edge mTLS client certificate for environment '%s'", envName), - ResourceType: &resourceType, + ResourceType: new("environment"), ResourceID: &envIDCopy, - ResourceName: &envNameCopy, + ResourceName: new(envName), EnvironmentID: &envIDCopy, Metadata: models.JSON{"kind": "client"}, }); err != nil { @@ -1285,12 +1225,12 @@ func buildMTLSDeploymentSnippetInternal(managerURL string, apiKey string, genera " - EDGE_AGENT=true", " - EDGE_TRANSPORT=poll", " - EDGE_MTLS_MODE=required", - fmt.Sprintf(" - EDGE_MTLS_ASSETS_DIR=%s", deploymentSnippetsMTLSPath), - fmt.Sprintf(" - AGENT_TOKEN=%s", apiKey), - fmt.Sprintf(" - MANAGER_API_URL=%s", managerURL), + " - EDGE_MTLS_ASSETS_DIR=" + deploymentSnippetsMTLSPath, + " - AGENT_TOKEN=" + apiKey, + " - MANAGER_API_URL=" + managerURL, " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", - fmt.Sprintf(" - arcane-data:%s", deploymentSnippetsDataPath), + " - arcane-data:" + deploymentSnippetsDataPath, "", "volumes:", " arcane-data:", @@ -1329,7 +1269,7 @@ func (s *EnvironmentService) resolveRemoteEnvironmentTargetInternal(ctx context. } if envID == "0" { - return nil, fmt.Errorf("cannot proxy request to local environment") + return nil, errors.New("cannot proxy request to local environment") } targetURL := strings.TrimRight(environment.ApiUrl, "/") @@ -1377,7 +1317,7 @@ func (s *EnvironmentService) getProxyRequestContextInternal(ctx context.Context) return timeouts.WithTimeout(ctx, settings.ProxyRequestTimeout.AsInt(), timeouts.DefaultProxyRequest) } - return context.WithTimeout(ctx, timeouts.DefaultProxyRequest) //nolint:gosec // helper intentionally returns the cancel func to callers. + return context.WithTimeout(ctx, timeouts.DefaultProxyRequest) } func (s *EnvironmentService) buildRemoteRequestInternal( @@ -1388,13 +1328,11 @@ func (s *EnvironmentService) buildRemoteRequestInternal( headers map[string]string, ) (remenv.Request, error) { if target == nil { - return remenv.Request{}, fmt.Errorf("remote environment target is required") + return remenv.Request{}, errors.New("remote environment target is required") } requestHeaders := make(map[string]string, len(headers)+2) - for key, value := range headers { - requestHeaders[key] = value - } + maps.Copy(requestHeaders, headers) if len(body) > 0 && method != http.MethodGet && requestHeaders["Content-Type"] == "" { requestHeaders["Content-Type"] = "application/json" } @@ -1483,7 +1421,7 @@ func ensureRemoteEnvironmentTunnelAvailableInternal(ctx context.Context, envID s return nil } - return fmt.Errorf("edge agent is not connected") + return errors.New("edge agent is not connected") } func doRemoteEnvironmentTunnelRequestInternal( diff --git a/backend/internal/services/environment_service_test.go b/backend/internal/services/environment_service_test.go index 4ad4174642..d90b868cd9 100644 --- a/backend/internal/services/environment_service_test.go +++ b/backend/internal/services/environment_service_test.go @@ -67,7 +67,6 @@ func createTestEnvironmentServiceUser(t *testing.T, ctx context.Context, userSer user := &models.User{ BaseModel: models.BaseModel{ID: id}, Username: fmt.Sprintf("user-%s", id), - Roles: models.StringSlice{"admin"}, } created, err := userService.CreateUser(ctx, user) @@ -267,7 +266,6 @@ func TestEnvironmentService_SyncRepositoriesToEnvironment_UsesAgentHeaders(t *te require.NoError(t, db.AutoMigrate(&models.GitRepository{})) svc := NewEnvironmentService(db, nil, nil, nil, nil, nil) - description := "test repo" createTestGitRepository(t, db, models.GitRepository{ BaseModel: models.BaseModel{ID: "repo-1", CreatedAt: time.Now()}, Name: "repo-1", @@ -276,7 +274,7 @@ func TestEnvironmentService_SyncRepositoriesToEnvironment_UsesAgentHeaders(t *te Username: "arcane", Token: encryptSecretForTest(t, "repo-token"), Enabled: true, - Description: &description, + Description: new("test repo"), }) accessToken := "token-1" @@ -738,8 +736,8 @@ func TestEnvironmentService_ListMethods_ExcludeHiddenEnvironments(t *testing.T) }).Error) listedEnvironments, _, err := svc.ListEnvironmentsPaginated(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - Filters: map[string]string{}, + Params: pagination.Params{Start: 0, Limit: 20}, + Filters: map[string]string{}, }) require.NoError(t, err) require.Len(t, listedEnvironments, 2) @@ -836,8 +834,8 @@ func TestEnvironmentService_ListEnvironmentsPaginated_FiltersByRuntimeType(t *te for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { listedEnvironments, _, err := svc.ListEnvironmentsPaginated(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - Filters: map[string]string{"type": tt.typeFilter}, + Params: pagination.Params{Start: 0, Limit: 20}, + Filters: map[string]string{"type": tt.typeFilter}, }) require.NoError(t, err) require.ElementsMatch(t, tt.wantIDs, environmentIDsInternal(listedEnvironments)) @@ -929,23 +927,13 @@ func TestEnvironmentService_GenerateEdgeDeploymentSnippets_ReturnsBasicSnippetsW require.Contains(t, snippets.DockerCompose, "MANAGER_API_URL=https://manager.example.com") } -func TestEnvironmentService_PairAgentWithBootstrap_RejectsInvalidURL(t *testing.T) { - svc := NewEnvironmentService(nil, nil, nil, nil, nil, nil) - - _, err := svc.PairAgentWithBootstrap(context.Background(), "ftp://example.com", "bootstrap-token") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid agent API URL") -} - func TestEnvironmentService_TestConnection_RejectsInvalidCustomURL(t *testing.T) { ctx := context.Background() db := setupEnvironmentServiceTestDB(t) svc := NewEnvironmentService(db, nil, nil, nil, nil, nil) createTestEnvironment(t, db, "env-1", "http://example.com", nil) - customURL := "ftp://example.com" - - status, err := svc.TestConnection(ctx, "env-1", &customURL) + status, err := svc.TestConnection(ctx, "env-1", new("ftp://example.com")) require.Error(t, err) require.Equal(t, "offline", status) require.Contains(t, err.Error(), "invalid environment API URL") diff --git a/backend/internal/services/event_service.go b/backend/internal/services/event_service.go index 3b8c063552..e685394b57 100644 --- a/backend/internal/services/event_service.go +++ b/backend/internal/services/event_service.go @@ -165,10 +165,10 @@ func (s *EventService) canForwardEventToManagerHTTP() bool { func (s *EventService) forwardEventToManagerHTTP(ctx context.Context, eventModel *models.Event) error { if eventModel == nil { - return fmt.Errorf("event is required") + return errors.New("event is required") } if s.cfg == nil || strings.TrimSpace(s.cfg.AgentToken) == "" { - return fmt.Errorf("agent token is required for manager event sync") + return errors.New("agent token is required for manager event sync") } managerEventsURL, err := managerEventEndpointURL(s.cfg.GetManagerBaseURL()) @@ -203,9 +203,9 @@ func (s *EventService) forwardEventToManagerHTTP(ctx context.Context, eventModel return fmt.Errorf("failed to create manager event request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", s.cfg.AgentToken) + req.Header.Set("X-Api-Key", s.cfg.AgentToken) - resp, err := s.httpClient.Do(req) //nolint:gosec // managerEventsURL is validated in managerEventEndpointURL before request + resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send event to manager: %w", err) } @@ -225,7 +225,7 @@ func (s *EventService) forwardEventToManagerHTTP(ctx context.Context, eventModel func managerEventEndpointURL(rawBaseURL string) (string, error) { trimmed := strings.TrimSpace(rawBaseURL) if trimmed == "" { - return "", fmt.Errorf("manager API URL is required") + return "", errors.New("manager API URL is required") } baseURL, err := url.Parse(trimmed) @@ -236,7 +236,7 @@ func managerEventEndpointURL(rawBaseURL string) (string, error) { return "", fmt.Errorf("unsupported scheme %q", baseURL.Scheme) } if baseURL.Host == "" { - return "", fmt.Errorf("manager API URL host is required") + return "", errors.New("manager API URL host is required") } baseURL.RawQuery = "" @@ -382,7 +382,7 @@ func (s *EventService) DeleteEvent(ctx context.Context, eventID string) error { return fmt.Errorf("failed to delete event: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("event not found") + return errors.New("event not found") } return nil }) @@ -534,7 +534,7 @@ func (s *EventService) LogErrorEvent(ctx context.Context, eventType models.Event eventMetadata["error"] = err.Error() titleCaser := cases.Title(language.English) - title := fmt.Sprintf("%s error", titleCaser.String(resourceType)) + title := titleCaser.String(resourceType) + " error" if resourceName != "" { title = fmt.Sprintf("%s error: %s", titleCaser.String(resourceType), resourceName) } @@ -636,8 +636,9 @@ var eventDefinitions = map[models.EventType]struct { models.EventTypeSystemAutoUpdate: {"System auto-update completed", "System auto-update process has completed", models.EventSeverityInfo}, models.EventTypeSystemUpgrade: {"System upgrade completed", "System upgrade process has completed", models.EventSeverityInfo}, - models.EventTypeUserLogin: {"User logged in: %s", "User '%s' has logged in", models.EventSeverityInfo}, - models.EventTypeUserLogout: {"User logged out: %s", "User '%s' has logged out", models.EventSeverityInfo}, + models.EventTypeUserLogin: {"User logged in: %s", "User '%s' has logged in", models.EventSeverityInfo}, + models.EventTypeUserLogout: {"User logged out: %s", "User '%s' has logged out", models.EventSeverityInfo}, + models.EventTypeFederatedExchange: {"Federated credential exchange: %s", "Federated credential exchange for '%s'", models.EventSeverityInfo}, } func (s *EventService) toEventDto(e *models.Event) *event.Event { @@ -669,7 +670,7 @@ func (s *EventService) generateEventTitle(eventType models.EventType, resourceNa if def, ok := eventDefinitions[eventType]; ok { return fmt.Sprintf(def.TitleFormat, resourceName) } - return fmt.Sprintf("Event: %s", string(eventType)) + return "Event: " + string(eventType) } func (s *EventService) generateEventDescription(eventType models.EventType, resourceType, resourceName string) string { diff --git a/backend/internal/services/federated_credential_service.go b/backend/internal/services/federated_credential_service.go new file mode 100644 index 0000000000..a996ab0a33 --- /dev/null +++ b/backend/internal/services/federated_credential_service.go @@ -0,0 +1,872 @@ +package services + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "golang.org/x/sync/singleflight" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" + "github.com/getarcaneapp/arcane/backend/pkg/utils/httpx" + "github.com/getarcaneapp/arcane/backend/pkg/utils/jwtclaims" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" +) + +const ( + federatedCredentialLastUsedWriteWindow = 5 * time.Minute + defaultFederatedSubjectClaim = "sub" +) + +type FederatedCredentialService struct { + db *database.DB + authService *AuthService + userService *UserService + settingsService *SettingsService + eventService *EventService + roleService *RoleService + httpClient *http.Client + providerMu sync.RWMutex + providers map[string]*oidc.Provider + providerGroup singleflight.Group +} + +func NewFederatedCredentialService( + db *database.DB, + authService *AuthService, + userService *UserService, + settingsService *SettingsService, + eventService *EventService, + httpClient *http.Client, +) *FederatedCredentialService { + if httpClient == nil { + httpClient = httpx.NewHTTPClientWithTimeout(15 * time.Second) + } + + return &FederatedCredentialService{ + db: db, + authService: authService, + userService: userService, + settingsService: settingsService, + eventService: eventService, + httpClient: httpClient, + providers: make(map[string]*oidc.Provider), + } +} + +func (s *FederatedCredentialService) WithRoleService(roleService *RoleService) *FederatedCredentialService { + s.roleService = roleService + return s +} + +func (s *FederatedCredentialService) ExchangeToken(ctx context.Context, req federatedtypes.TokenExchangeRequest) (*federatedtypes.FederatedTokenResponse, error) { + issuer, subject, audiences := unverifiedTokenExchangeMetadataInternal(req.SubjectToken) + logResult := "failure" + logReason := "" + var matchedCredential *models.FederatedCredential + var matchedUser *models.User + defer func() { + s.logExchangeInternal(ctx, logResult, logReason, issuer, subject, audiences, matchedCredential, matchedUser) + }() + + if err := validateTokenExchangeRequestInternal(req); err != nil { + logReason = "invalid_request" + return nil, err + } + if issuer == "" { + logReason = "missing_issuer" + return nil, &common.FederatedCredentialInvalidGrantError{} + } + + credentials, err := s.listEnabledCredentialsForIssuerInternal(ctx, issuer) + if err != nil { + logReason = "credential_lookup_failed" + return nil, err + } + if len(credentials) == 0 { + logReason = "issuer_not_allowed" + return nil, &common.FederatedCredentialInvalidGrantError{} + } + + verifiedToken, verifiedClaims, err := s.verifySubjectTokenInternal(ctx, issuer, req.SubjectToken) + if err != nil { + logReason = "token_verification_failed" + return nil, fmt.Errorf("%w: %w", &common.FederatedCredentialInvalidGrantError{}, err) + } + if subject == "" { + subject = stringClaimByPathInternal(verifiedClaims, defaultFederatedSubjectClaim) + } + if len(audiences) == 0 { + audiences = append([]string{}, verifiedToken.Audience...) + } + + credential := selectMatchingCredentialInternal(credentials, verifiedToken.Audience, verifiedClaims) + if credential == nil { + logReason = "no_matching_credential" + return nil, &common.FederatedCredentialInvalidGrantError{} + } + matchedCredential = credential + if err := s.recordTokenReplayGuardInternal(ctx, issuer, req.SubjectToken, verifiedClaims, verifiedToken.Expiry); err != nil { + logReason = "token_replay_rejected" + return nil, err + } + + user, err := s.userService.GetUserByID(ctx, credential.IdentityUserID) + if err != nil { + logReason = "identity_user_missing" + return nil, fmt.Errorf("%w: %w", &common.FederatedCredentialInvalidGrantError{}, err) + } + matchedUser = user + + tokenPair, err := s.authService.IssueFederatedToken(ctx, user, credential.ID, credential.TokenTTLSeconds) + if err != nil { + logReason = "token_issue_failed" + return nil, err + } + + s.markCredentialUsedAsyncInternal(ctx, credential.ID) + logResult = "success" + logReason = "matched" + + return &federatedtypes.FederatedTokenResponse{ + AccessToken: tokenPair.AccessToken, + TokenType: "Bearer", + ExpiresIn: max(int(time.Until(tokenPair.ExpiresAt).Seconds()), 0), + IssuedTokenType: federatedtypes.IssuedTokenTypeAccessToken, + }, nil +} + +func (s *FederatedCredentialService) Create(ctx context.Context, callerUserID string, req federatedtypes.CreateFederatedCredential) (*federatedtypes.FederatedCredential, error) { + normalized, err := normalizeCreateFederatedCredentialInternal(req) + if err != nil { + return nil, err + } + if err := s.validateRoleGrantAgainstUserInternal(ctx, callerUserID, normalized.RoleID, normalized.EnvironmentID); err != nil { + return nil, err + } + + var created models.FederatedCredential + err = dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + serviceUser := models.User{ + Username: "svc_federated_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + DisplayName: pkgutils.StringPtrFromTrimmed("Federated: " + normalized.Name), + IsServiceAccount: true, + } + if err := tx.Create(&serviceUser).Error; err != nil { + return fmt.Errorf("failed to create federated service user: %w", err) + } + + created = models.FederatedCredential{ + Name: normalized.Name, + Description: normalized.Description, + Enabled: normalized.Enabled, + IssuerURL: normalized.IssuerURL, + Audiences: models.StringSlice(normalized.Audiences), + SubjectClaim: normalized.SubjectClaim, + SubjectMatch: normalized.SubjectMatch, + MatchType: normalized.MatchType, + RoleID: normalized.RoleID, + EnvironmentID: normalized.EnvironmentID, + IdentityUserID: serviceUser.ID, + TokenTTLSeconds: normalized.TokenTTLSeconds, + ExpiresAt: normalized.ExpiresAt, + } + if err := tx.Create(&created).Error; err != nil { + return fmt.Errorf("failed to create federated credential: %w", err) + } + + assignment := models.UserRoleAssignment{ + UserID: serviceUser.ID, + RoleID: normalized.RoleID, + EnvironmentID: normalized.EnvironmentID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to create federated role assignment: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + if s.roleService != nil { + s.roleService.InvalidateUser(created.IdentityUserID) + } + + reloaded, err := s.Get(ctx, created.ID) + if err != nil { + return nil, err + } + return reloaded, nil +} + +func (s *FederatedCredentialService) List(ctx context.Context, params pagination.QueryParams) ([]federatedtypes.FederatedCredential, pagination.Response, error) { + var credentials []models.FederatedCredential + query := s.db.WithContext(ctx). + Model(&models.FederatedCredential{}). + Preload("IdentityUser"). + Preload("Role"). + Preload("Environment") + + if term := strings.TrimSpace(params.Search); term != "" { + pattern := "%" + term + "%" + query = query.Where("name LIKE ? OR COALESCE(description, '') LIKE ? OR issuer_url LIKE ? OR subject_match LIKE ?", pattern, pattern, pattern, pattern) + } + + resp, err := pagination.PaginateAndSortDB(params, query, &credentials) + if err != nil { + return nil, pagination.Response{}, fmt.Errorf("failed to paginate federated credentials: %w", err) + } + + result := make([]federatedtypes.FederatedCredential, len(credentials)) + for i := range credentials { + result[i] = toFederatedCredentialDTOInternal(&credentials[i]) + } + return result, resp, nil +} + +func (s *FederatedCredentialService) Get(ctx context.Context, id string) (*federatedtypes.FederatedCredential, error) { + var credential models.FederatedCredential + if err := s.db.WithContext(ctx). + Preload("IdentityUser"). + Preload("Role"). + Preload("Environment"). + Where("id = ?", id). + First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, &common.FederatedCredentialNotFoundError{} + } + return nil, fmt.Errorf("failed to get federated credential: %w", err) + } + dto := toFederatedCredentialDTOInternal(&credential) + return &dto, nil +} + +func (s *FederatedCredentialService) Update(ctx context.Context, callerUserID string, id string, req federatedtypes.UpdateFederatedCredential) (*federatedtypes.FederatedCredential, error) { + var credential models.FederatedCredential + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, &common.FederatedCredentialNotFoundError{} + } + return nil, fmt.Errorf("failed to load federated credential: %w", err) + } + + updated, roleChanged, err := applyFederatedCredentialUpdateInternal(credential, req) + if err != nil { + return nil, err + } + revokeActiveSessions := credential.Enabled && !updated.Enabled + if roleChanged { + if err := s.validateRoleGrantAgainstUserInternal(ctx, callerUserID, updated.RoleID, updated.EnvironmentID); err != nil { + return nil, err + } + } + + err = dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Save(&updated).Error; err != nil { + return fmt.Errorf("failed to update federated credential: %w", err) + } + if revokeActiveSessions { + if err := revokeFederatedCredentialSessionsInternal(tx, updated.ID); err != nil { + return err + } + } + if roleChanged { + if err := tx.Where("user_id = ? AND source = ?", updated.IdentityUserID, models.RoleAssignmentSourceManual). + Delete(&models.UserRoleAssignment{}).Error; err != nil { + return fmt.Errorf("failed to clear federated role assignment: %w", err) + } + assignment := models.UserRoleAssignment{ + UserID: updated.IdentityUserID, + RoleID: updated.RoleID, + EnvironmentID: updated.EnvironmentID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to update federated role assignment: %w", err) + } + } + return nil + }) + if err != nil { + return nil, err + } + if roleChanged && s.roleService != nil { + s.roleService.InvalidateUser(updated.IdentityUserID) + } + + return s.Get(ctx, id) +} + +func (s *FederatedCredentialService) Delete(ctx context.Context, id string) error { + var credential models.FederatedCredential + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.FederatedCredentialNotFoundError{} + } + return fmt.Errorf("failed to load federated credential: %w", err) + } + + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Delete(&models.FederatedCredential{}, "id = ?", credential.ID).Error; err != nil { + return fmt.Errorf("failed to delete federated credential: %w", err) + } + if err := tx.Delete(&models.User{}, "id = ?", credential.IdentityUserID).Error; err != nil { + return fmt.Errorf("failed to delete federated service user: %w", err) + } + return nil + }) + if err != nil { + return err + } + if s.roleService != nil { + s.roleService.InvalidateUser(credential.IdentityUserID) + } + return nil +} + +func validateTokenExchangeRequestInternal(req federatedtypes.TokenExchangeRequest) error { + if req.GrantType != federatedtypes.TokenExchangeGrantType { + return &common.FederatedCredentialInvalidRequestError{} + } + if strings.TrimSpace(req.SubjectToken) == "" { + return &common.FederatedCredentialInvalidRequestError{} + } + switch req.SubjectTokenType { + case federatedtypes.SubjectTokenTypeJWT, federatedtypes.SubjectTokenTypeIDToken: + default: + return &common.FederatedCredentialInvalidRequestError{} + } + if req.RequestedTokenType != "" && req.RequestedTokenType != federatedtypes.RequestedTokenTypeAccessJWT { + return &common.FederatedCredentialInvalidRequestError{} + } + return nil +} + +func (s *FederatedCredentialService) listEnabledCredentialsForIssuerInternal(ctx context.Context, issuer string) ([]models.FederatedCredential, error) { + var credentials []models.FederatedCredential + if err := s.db.WithContext(ctx). + Where("issuer_url = ? AND enabled = ?", issuer, true). + Order("created_at ASC"). + Order("id ASC"). + Find(&credentials).Error; err != nil { + return nil, fmt.Errorf("failed to list federated credentials for issuer: %w", err) + } + + now := time.Now() + active := credentials[:0] + for _, credential := range credentials { + if credential.ExpiresAt != nil && now.After(*credential.ExpiresAt) { + continue + } + active = append(active, credential) + } + return active, nil +} + +func (s *FederatedCredentialService) verifySubjectTokenInternal(ctx context.Context, issuer string, rawToken string) (*oidc.IDToken, map[string]any, error) { + provider, err := s.providerForIssuerInternal(ctx, issuer) + if err != nil { + return nil, nil, err + } + + providerCtx := oidc.ClientContext(ctx, s.httpClient) + verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + idToken, err := verifier.Verify(providerCtx, rawToken) + if err != nil { + return nil, nil, err + } + + claims := map[string]any{} + if err := idToken.Claims(&claims); err != nil { + return nil, nil, err + } + return idToken, claims, nil +} + +func (s *FederatedCredentialService) recordTokenReplayGuardInternal(ctx context.Context, issuer string, rawToken string, claims map[string]any, expiresAt time.Time) error { + if expiresAt.IsZero() || time.Now().After(expiresAt) { + return &common.FederatedCredentialInvalidGrantError{} + } + + now := time.Now() + if err := s.db.WithContext(ctx). + Where("expires_at < ?", now). + Delete(&models.FederatedTokenReplay{}).Error; err != nil { + return fmt.Errorf("failed to prune federated token replay records: %w", err) + } + + replay := models.FederatedTokenReplay{ + TokenHash: federatedTokenReplayHashInternal(issuer, rawToken, claims), + IssuerURL: issuer, + ExpiresAt: expiresAt, + } + if err := s.db.WithContext(ctx).Create(&replay).Error; err != nil { + if isUniqueConstraintErrorInternal(err) { + return &common.FederatedCredentialInvalidGrantError{} + } + return fmt.Errorf("failed to record federated token replay guard: %w", err) + } + return nil +} + +func federatedTokenReplayHashInternal(issuer string, rawToken string, claims map[string]any) string { + tokenID := strings.TrimSpace(stringClaimByPathInternal(claims, "jti")) + tokenKind := "jti" + if tokenID == "" { + tokenID = rawToken + tokenKind = "token" + } + + sum := sha256.Sum256([]byte(issuer + "\x00" + tokenKind + "\x00" + tokenID)) + return hex.EncodeToString(sum[:]) +} + +func isUniqueConstraintErrorInternal(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate key") +} + +func (s *FederatedCredentialService) providerForIssuerInternal(ctx context.Context, issuer string) (*oidc.Provider, error) { + s.providerMu.RLock() + if provider := s.providers[issuer]; provider != nil { + s.providerMu.RUnlock() + return provider, nil + } + s.providerMu.RUnlock() + + v, err, _ := s.providerGroup.Do(issuer, func() (any, error) { + providerCtx := oidc.ClientContext(context.WithoutCancel(ctx), s.httpClient) + provider, err := oidc.NewProvider(providerCtx, issuer) + if err != nil { + return nil, fmt.Errorf("failed to discover federated issuer: %w", err) + } + + s.providerMu.Lock() + s.providers[issuer] = provider + s.providerMu.Unlock() + return provider, nil + }) + if err != nil { + return nil, err + } + + provider, ok := v.(*oidc.Provider) + if !ok || provider == nil { + return nil, errors.New("federated issuer discovery returned invalid provider") + } + return provider, nil +} + +func selectMatchingCredentialInternal(credentials []models.FederatedCredential, tokenAudiences []string, claims map[string]any) *models.FederatedCredential { + for i := range credentials { + credential := &credentials[i] + if !audienceMatchesInternal(tokenAudiences, []string(credential.Audiences)) { + continue + } + subjectClaim := strings.TrimSpace(credential.SubjectClaim) + if subjectClaim == "" { + subjectClaim = defaultFederatedSubjectClaim + } + subject := stringClaimByPathInternal(claims, subjectClaim) + if !subjectMatchesInternal(credential.MatchType, credential.SubjectMatch, subject) { + continue + } + return credential + } + return nil +} + +func audienceMatchesInternal(tokenAudiences, credentialAudiences []string) bool { + allowed := make(map[string]struct{}, len(credentialAudiences)) + for _, audience := range credentialAudiences { + audience = strings.TrimSpace(audience) + if audience != "" { + allowed[audience] = struct{}{} + } + } + for _, audience := range tokenAudiences { + if _, ok := allowed[audience]; ok { + return true + } + } + return false +} + +func subjectMatchesInternal(matchType, pattern, subject string) bool { + if subject == "" { + return false + } + switch normalizeMatchTypeInternal(matchType) { + case federatedtypes.MatchTypeGlob: + return anchoredGlobMatchesInternal(pattern, subject) + default: + return subject == pattern + } +} + +func anchoredGlobMatchesInternal(pattern, value string) bool { + var b strings.Builder + b.WriteString("^") + for _, r := range pattern { + switch r { + case '*': + b.WriteString(".*") + case '?': + b.WriteByte('.') + default: + b.WriteString(regexp.QuoteMeta(string(r))) + } + } + b.WriteString("$") + matched, err := regexp.MatchString(b.String(), value) + return err == nil && matched +} + +func unverifiedTokenExchangeMetadataInternal(rawToken string) (string, string, []string) { + claims := jwtclaims.ParseJWTClaims(rawToken) + if claims == nil { + return "", "", nil + } + return stringClaimByPathInternal(claims, "iss"), stringClaimByPathInternal(claims, "sub"), stringSliceClaimInternal(claims, "aud") +} + +func stringClaimByPathInternal(claims map[string]any, path string) string { + value, ok := jwtclaims.GetByPath(claims, path) + if !ok { + return "" + } + return pkgutils.ToString(value) +} + +func stringSliceClaimInternal(claims map[string]any, path string) []string { + value, ok := jwtclaims.GetByPath(claims, path) + if !ok || value == nil { + return nil + } + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) == "" { + return nil + } + return []string{strings.TrimSpace(typed)} + case []string: + return pkgutils.UniqueNonEmptyStrings(typed) + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if value := pkgutils.ToString(item); value != "" { + out = append(out, value) + } + } + return pkgutils.UniqueNonEmptyStrings(out) + default: + return nil + } +} + +func normalizeCreateFederatedCredentialInternal(req federatedtypes.CreateFederatedCredential) (federatedtypes.CreateFederatedCredential, error) { + req.Name = strings.TrimSpace(req.Name) + req.IssuerURL = strings.TrimRight(strings.TrimSpace(req.IssuerURL), "/") + req.SubjectClaim = strings.TrimSpace(req.SubjectClaim) + req.SubjectMatch = strings.TrimSpace(req.SubjectMatch) + req.MatchType = normalizeMatchTypeInternal(req.MatchType) + req.Audiences = pkgutils.UniqueNonEmptyStrings(req.Audiences) + req.EnvironmentID = pkgutils.StringPtrFromTrimmed(pkgutils.DerefString(req.EnvironmentID)) + req.TokenTTLSeconds = clampFederatedTokenTTLSecondsInternal(req.TokenTTLSeconds) + + if req.SubjectClaim == "" { + req.SubjectClaim = defaultFederatedSubjectClaim + } + if req.Name == "" || req.SubjectMatch == "" || req.RoleID == "" || len(req.Audiences) == 0 { + return req, &common.FederatedCredentialInvalidError{} + } + if err := validateIssuerURLInternal(req.IssuerURL); err != nil { + return req, err + } + if err := validateSubjectMatchInternal(req.MatchType, req.SubjectMatch); err != nil { + return req, err + } + return req, nil +} + +func applyFederatedCredentialUpdateInternal(existing models.FederatedCredential, req federatedtypes.UpdateFederatedCredential) (models.FederatedCredential, bool, error) { + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return existing, false, &common.FederatedCredentialInvalidError{} + } + existing.Name = name + } + if req.Description != nil { + existing.Description = req.Description + } + if req.Enabled != nil { + existing.Enabled = *req.Enabled + } + if req.IssuerURL != nil { + issuerURL := strings.TrimRight(strings.TrimSpace(*req.IssuerURL), "/") + if err := validateIssuerURLInternal(issuerURL); err != nil { + return existing, false, err + } + existing.IssuerURL = issuerURL + } + if req.Audiences != nil { + audiences := pkgutils.UniqueNonEmptyStrings(req.Audiences) + if len(audiences) == 0 { + return existing, false, &common.FederatedCredentialInvalidError{} + } + existing.Audiences = models.StringSlice(audiences) + } + if req.SubjectClaim != nil { + subjectClaim := strings.TrimSpace(*req.SubjectClaim) + if subjectClaim == "" { + subjectClaim = defaultFederatedSubjectClaim + } + existing.SubjectClaim = subjectClaim + } + if req.SubjectMatch != nil { + subjectMatch := strings.TrimSpace(*req.SubjectMatch) + if subjectMatch == "" { + return existing, false, &common.FederatedCredentialInvalidError{} + } + existing.SubjectMatch = subjectMatch + } + if req.MatchType != nil { + existing.MatchType = normalizeMatchTypeInternal(*req.MatchType) + } + if err := validateSubjectMatchInternal(existing.MatchType, existing.SubjectMatch); err != nil { + return existing, false, err + } + roleChanged, err := applyFederatedRoleScopeUpdateInternal(&existing, req.RoleID, req.EnvironmentID) + if err != nil { + return existing, false, err + } + if req.TokenTTLSeconds != nil { + existing.TokenTTLSeconds = clampFederatedTokenTTLSecondsInternal(*req.TokenTTLSeconds) + } + if req.ExpiresAt != nil { + existing.ExpiresAt = req.ExpiresAt + } + return existing, roleChanged, nil +} + +func applyFederatedRoleScopeUpdateInternal(existing *models.FederatedCredential, roleID *string, environmentID *string) (bool, error) { + if existing == nil { + return false, &common.FederatedCredentialInvalidError{} + } + + roleChanged := false + if roleID != nil { + normalizedRoleID := strings.TrimSpace(*roleID) + if normalizedRoleID == "" { + return false, &common.FederatedCredentialInvalidError{} + } + roleChanged = roleChanged || normalizedRoleID != existing.RoleID + existing.RoleID = normalizedRoleID + } + if environmentID != nil { + normalized := pkgutils.StringPtrFromTrimmed(*environmentID) + roleChanged = roleChanged || pkgutils.DerefString(existing.EnvironmentID) != pkgutils.DerefString(normalized) + existing.EnvironmentID = normalized + } + return roleChanged, nil +} + +func normalizeMatchTypeInternal(matchType string) string { + switch strings.ToLower(strings.TrimSpace(matchType)) { + case federatedtypes.MatchTypeGlob: + return federatedtypes.MatchTypeGlob + default: + return federatedtypes.MatchTypeExact + } +} + +func validateIssuerURLInternal(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed == nil || parsed.Host == "" || parsed.Scheme != "https" { + return fmt.Errorf("%w: issuerUrl must be an HTTPS URL", &common.FederatedCredentialInvalidError{}) + } + return nil +} + +func validateSubjectMatchInternal(matchType, subjectMatch string) error { + if strings.TrimSpace(subjectMatch) == "" { + return &common.FederatedCredentialInvalidError{} + } + if normalizeMatchTypeInternal(matchType) == federatedtypes.MatchTypeGlob && strings.TrimSpace(subjectMatch) == "*" { + return &common.FederatedCredentialInvalidError{} + } + return nil +} + +func (s *FederatedCredentialService) validateRoleGrantAgainstUserInternal(ctx context.Context, userID, roleID string, environmentID *string) error { + if s.roleService == nil || strings.TrimSpace(userID) == "" { + return nil + } + + user, err := s.userService.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("load user for federated role validation: %w", err) + } + + ps, err := s.roleService.ResolvePermissions(ctx, user) + if err != nil { + return fmt.Errorf("resolve user permissions: %w", err) + } + + if err := s.roleService.ValidateRoleAssignmentAgainstCaller(ctx, ps, roleID, environmentID); err != nil { + if common.IsRolePermissionEscalationError(err) { + return fmt.Errorf("%w: %w", &common.FederatedCredentialPermissionEscalationError{}, err) + } + return fmt.Errorf("%w: %w", &common.FederatedCredentialInvalidError{}, err) + } + return nil +} + +func revokeFederatedCredentialSessionsInternal(tx *gorm.DB, credentialID string) error { + if tx == nil || strings.TrimSpace(credentialID) == "" { + return nil + } + + now := time.Now() + if err := tx.Model(&models.UserSession{}). + Where("federated_credential_id = ? AND revoked_at IS NULL", credentialID). + Updates(map[string]any{"revoked_at": now, "updated_at": now}).Error; err != nil { + return fmt.Errorf("failed to revoke federated credential sessions: %w", err) + } + return nil +} + +func toFederatedCredentialDTOInternal(credential *models.FederatedCredential) federatedtypes.FederatedCredential { + if credential == nil { + return federatedtypes.FederatedCredential{} + } + dto := federatedtypes.FederatedCredential{ + ID: credential.ID, + Name: credential.Name, + Description: credential.Description, + Enabled: credential.Enabled, + IssuerURL: credential.IssuerURL, + Audiences: []string(credential.Audiences), + SubjectClaim: credential.SubjectClaim, + SubjectMatch: credential.SubjectMatch, + MatchType: credential.MatchType, + RoleID: credential.RoleID, + EnvironmentID: credential.EnvironmentID, + IdentityUserID: credential.IdentityUserID, + TokenTTLSeconds: credential.TokenTTLSeconds, + LastUsedAt: credential.LastUsedAt, + ExpiresAt: credential.ExpiresAt, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } + if credential.IdentityUser != nil { + dto.ServiceUsername = credential.IdentityUser.Username + } + if credential.Role != nil { + dto.RoleName = credential.Role.Name + } + if credential.Environment != nil { + dto.EnvironmentName = credential.Environment.Name + } + return dto +} + +func (s *FederatedCredentialService) markCredentialUsedAsyncInternal(ctx context.Context, credentialID string) { + go func() { + bgCtx := context.WithoutCancel(ctx) + now := time.Now() + cutoff := now.Add(-federatedCredentialLastUsedWriteWindow) + if err := s.db.WithContext(bgCtx). + Model(&models.FederatedCredential{}). + Where("id = ? AND (last_used_at IS NULL OR last_used_at < ?)", credentialID, cutoff). + Update("last_used_at", now).Error; err != nil { + slog.WarnContext(bgCtx, "failed to update federated credential last_used_at", "credential_id", credentialID, "error", err) + } + }() +} + +func (s *FederatedCredentialService) logExchangeInternal(ctx context.Context, result, reason, issuer, subject string, audiences []string, credential *models.FederatedCredential, user *models.User) { + credentialID := "" + credentialName := "" + if credential != nil { + credentialID = credential.ID + credentialName = credential.Name + } + slog.InfoContext(ctx, "Federated credential token exchange", + "result", result, + "reason", reason, + "issuer", issuer, + "subject", subject, + "audiences", audiences, + "credential_id", credentialID, + ) + + if s.eventService == nil { + return + } + + metadata := models.JSON{ + "action": "federated_token_exchange", + "result": result, + "reason": reason, + "issuer": issuer, + "subject": subject, + "audiences": audiences, + "credentialId": credentialID, + } + + userID := "" + username := "" + if user != nil { + userID = user.ID + username = user.Username + } + severity := models.EventSeverityInfo + title := "Federated credential token exchange" + if result != "success" { + severity = models.EventSeverityWarning + title = "Federated credential token exchange rejected" + } + + go func() { + bgCtx := context.WithoutCancel(ctx) + _, err := s.eventService.CreateEvent(bgCtx, CreateEventRequest{ + Type: models.EventTypeFederatedExchange, + Severity: severity, + Title: title, + Description: "Workload identity federation token exchange", + ResourceType: pkgutils.StringPtrFromTrimmed("federated_credential"), + ResourceID: pkgutils.StringPtrFromTrimmed(credentialID), + ResourceName: pkgutils.StringPtrFromTrimmed(credentialName), + UserID: pkgutils.StringPtrFromTrimmed(userID), + Username: pkgutils.StringPtrFromTrimmed(username), + Metadata: metadata, + }) + if err != nil { + slog.WarnContext(bgCtx, "failed to audit federated credential token exchange", "error", err) + } + }() +} diff --git a/backend/internal/services/federated_credential_service_test.go b/backend/internal/services/federated_credential_service_test.go new file mode 100644 index 0000000000..745d982bdf --- /dev/null +++ b/backend/internal/services/federated_credential_service_test.go @@ -0,0 +1,363 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/config" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" +) + +type federatedTestIssuerInternal struct { + IssuerURL string + private *rsa.PrivateKey + keyID string + server *httptest.Server +} + +func newFederatedTestIssuerInternal(t *testing.T) *federatedTestIssuerInternal { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + issuer := &federatedTestIssuerInternal{ + private: privateKey, + keyID: "federated-test-key", + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer.IssuerURL, + "jwks_uri": issuer.IssuerURL + "/jwks", + "authorization_endpoint": issuer.IssuerURL + "/authorize", + "token_endpoint": issuer.IssuerURL + "/token", + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + })) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + pub := privateKey.PublicKey + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "use": "sig", + "kid": issuer.keyID, + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }, + }, + })) + }) + + issuer.server = httptest.NewServer(mux) + issuer.IssuerURL = issuer.server.URL + t.Cleanup(issuer.server.Close) + + return issuer +} + +func (i *federatedTestIssuerInternal) tokenInternal(t *testing.T, subject string, audience []string) string { + t.Helper() + + now := time.Now() + claims := jwt.MapClaims{ + "iss": i.IssuerURL, + "sub": subject, + "aud": audience, + "iat": now.Unix(), + "nbf": now.Add(-time.Minute).Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = i.keyID + signed, err := token.SignedString(i.private) + require.NoError(t, err) + return signed +} + +func setupFederatedCredentialServiceTestDBInternal(t *testing.T) *database.DB { + t.Helper() + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())) + db, err := gorm.Open(glsqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.SettingVariable{}, + &models.User{}, + &models.UserSession{}, + &models.Role{}, + &models.UserRoleAssignment{}, + &models.FederatedCredential{}, + &models.FederatedTokenReplay{}, + &models.Event{}, + )) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + return &database.DB{DB: db} +} + +func setupFederatedCredentialServiceInternal(t *testing.T, issuer *federatedTestIssuerInternal) (*FederatedCredentialService, *AuthService, *database.DB) { + t.Helper() + + ctx := context.Background() + db := setupFederatedCredentialServiceTestDBInternal(t) + roleSvc := NewRoleService(db) + userSvc := NewUserService(db).WithRoleService(roleSvc) + sessionSvc := NewSessionService(db) + settingsSvc, err := NewSettingsService(ctx, db) + require.NoError(t, err) + eventSvc := NewEventService(db, &config.Config{}, nil) + authSvc := NewAuthService(userSvc, settingsSvc, eventSvc, sessionSvc, roleSvc, "test-federated-secret", &config.Config{ + JWTRefreshExpiry: 24 * time.Hour, + }) + + service := NewFederatedCredentialService(db, authSvc, userSvc, settingsSvc, eventSvc, issuer.server.Client()).WithRoleService(roleSvc) + + role := models.Role{ + BaseModel: models.BaseModel{ID: "role-federated-viewer"}, + Name: "Federated Viewer", + Permissions: models.StringSlice{authz.PermProjectsList}, + } + require.NoError(t, db.WithContext(ctx).Create(&role).Error) + + serviceUser := models.User{ + BaseModel: models.BaseModel{ID: "user-federated-service"}, + Username: "svc-federated-demo", + IsServiceAccount: true, + } + require.NoError(t, db.WithContext(ctx).Create(&serviceUser).Error) + require.NoError(t, db.WithContext(ctx).Create(&models.UserRoleAssignment{ + UserID: serviceUser.ID, + RoleID: role.ID, + }).Error) + + credential := models.FederatedCredential{ + BaseModel: models.BaseModel{ID: "cred-github-actions"}, + Name: "GitHub Actions", + Enabled: true, + IssuerURL: issuer.IssuerURL, + Audiences: models.StringSlice{"arcane-ci"}, + SubjectClaim: "sub", + SubjectMatch: "repo:getarcaneapp/arcane:*", + MatchType: federatedtypes.MatchTypeGlob, + RoleID: role.ID, + IdentityUserID: serviceUser.ID, + TokenTTLSeconds: 900, + } + require.NoError(t, db.WithContext(ctx).Create(&credential).Error) + + return service, authSvc, db +} + +func TestFederatedCredentialServiceExchangeToken(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, authSvc, db := setupFederatedCredentialServiceInternal(t, issuer) + ctx := context.Background() + + tests := []struct { + name string + token string + wantError func(error) bool + }{ + { + name: "issues an Arcane bearer token for a matching issuer audience and subject", + token: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + }, + { + name: "rejects audience mismatch", + token: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"other-audience"}), + wantError: func(err error) bool { + return common.IsErrorFederatedCredentialInvalidGrant(err) + }, + }, + { + name: "rejects subject mismatch", + token: issuer.tokenInternal(t, "repo:other/repo:ref:refs/heads/main", []string{"arcane-ci"}), + wantError: func(err error) bool { + return common.IsErrorFederatedCredentialInvalidGrant(err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.ExchangeToken(ctx, federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: tt.token, + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + if tt.wantError != nil { + require.Error(t, err) + require.True(t, tt.wantError(err), "unexpected error: %v", err) + require.Nil(t, resp) + return + } + + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "Bearer", resp.TokenType) + require.Equal(t, federatedtypes.IssuedTokenTypeAccessToken, resp.IssuedTokenType) + require.Positive(t, resp.ExpiresIn) + require.NotEmpty(t, resp.AccessToken) + + user, sessionID, err := authSvc.VerifyToken(ctx, resp.AccessToken) + require.NoError(t, err) + require.Equal(t, "user-federated-service", user.ID) + + var session models.UserSession + require.NoError(t, db.WithContext(ctx).Where("id = ?", sessionID).First(&session).Error) + require.Equal(t, models.UserSessionSourceFederated, session.Source) + require.NotNil(t, session.FederatedCredentialID) + require.Equal(t, "cred-github-actions", *session.FederatedCredentialID) + }) + } +} + +func TestFederatedCredentialServiceExchangeTokenRejectsIssuerWithoutCredentialInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + otherIssuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + + resp, err := service.ExchangeToken(context.Background(), federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: otherIssuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalidGrant(err), "unexpected error: %v", err) + require.Nil(t, resp) +} + +func TestFederatedCredentialServiceExchangeTokenDoesNotRequireGlobalFeatureFlagInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + service.settingsService = nil + + resp, err := service.ExchangeToken(context.Background(), federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.AccessToken) +} + +func TestFederatedCredentialServiceExchangeTokenRejectsExpiredCredentialInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, db := setupFederatedCredentialServiceInternal(t, issuer) + expiredAt := time.Now().Add(-time.Minute) + require.NoError(t, db.WithContext(context.Background()). + Model(&models.FederatedCredential{}). + Where("id = ?", "cred-github-actions"). + Update("expires_at", expiredAt).Error) + + resp, err := service.ExchangeToken(context.Background(), federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalidGrant(err), "unexpected error: %v", err) + require.Nil(t, resp) +} + +func TestFederatedCredentialServiceUpdateDisableRevokesIssuedSessionsInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, authSvc, _ := setupFederatedCredentialServiceInternal(t, issuer) + ctx := context.Background() + + resp, err := service.ExchangeToken(ctx, federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}), + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + }) + require.NoError(t, err) + require.NotNil(t, resp) + + disabled := false + _, err = service.Update(ctx, "admin-user", "cred-github-actions", federatedtypes.UpdateFederatedCredential{ + Enabled: &disabled, + }) + require.NoError(t, err) + + _, _, err = authSvc.VerifyToken(ctx, resp.AccessToken) + require.Error(t, err) + require.True(t, common.IsSessionRevokedError(err), "unexpected error: %v", err) +} + +func TestFederatedCredentialServiceRejectsReplayedSubjectTokenInternal(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + ctx := context.Background() + subjectToken := issuer.tokenInternal(t, "repo:getarcaneapp/arcane:ref:refs/heads/main", []string{"arcane-ci"}) + req := federatedtypes.TokenExchangeRequest{ + GrantType: federatedtypes.TokenExchangeGrantType, + SubjectToken: subjectToken, + SubjectTokenType: federatedtypes.SubjectTokenTypeJWT, + Audience: "https://arcane.example.com", + } + + first, err := service.ExchangeToken(ctx, req) + require.NoError(t, err) + require.NotNil(t, first) + + second, err := service.ExchangeToken(ctx, req) + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalidGrant(err), "unexpected error: %v", err) + require.Nil(t, second) +} + +func TestFederatedCredentialServiceCreateRejectsBareWildcardGlob(t *testing.T) { + issuer := newFederatedTestIssuerInternal(t) + service, _, _ := setupFederatedCredentialServiceInternal(t, issuer) + + _, err := service.Create(context.Background(), "admin-user", federatedtypes.CreateFederatedCredential{ + Name: "Unsafe wildcard", + IssuerURL: "https://token.actions.githubusercontent.com", + Audiences: []string{"arcane-ci"}, + SubjectMatch: "*", + MatchType: federatedtypes.MatchTypeGlob, + RoleID: "role-federated-viewer", + TokenTTLSeconds: 900, + }) + + require.Error(t, err) + require.True(t, common.IsErrorFederatedCredentialInvalid(err), "unexpected error: %v", err) +} diff --git a/backend/internal/services/font_service.go b/backend/internal/services/font_service.go deleted file mode 100644 index 547bfe4e2d..0000000000 --- a/backend/internal/services/font_service.go +++ /dev/null @@ -1,65 +0,0 @@ -package services - -import ( - "embed" - "errors" - "fmt" - "io/fs" - "mime" - "path/filepath" - "strings" -) - -type FontService struct { - fs embed.FS -} - -func NewFontService(embeddedFS embed.FS) *FontService { - return &FontService{ - fs: embeddedFS, - } -} - -func (s *FontService) GetSansFont() ([]byte, string, error) { - return s.GetFont("Mona/MonaSans.woff2") -} - -func (s *FontService) GetMonoFont() ([]byte, string, error) { - return s.GetFont("Mona/MonaSansMono.woff2") -} - -func (s *FontService) GetFont(fontPath string) ([]byte, string, error) { - // Prevent directory traversal - if strings.Contains(fontPath, "..") { - return nil, "", fmt.Errorf("invalid font path") - } - - // The fonts are located in "fonts" directory in the embedded FS - fullPath := filepath.Join("fonts", fontPath) - - data, err := s.fs.ReadFile(fullPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, "", fmt.Errorf("font not found") - } - return nil, "", err - } - - ext := filepath.Ext(fontPath) - mimeType := mime.TypeByExtension(ext) - if mimeType == "" { - // Fallback for common font types if mime package doesn't detect them - switch strings.ToLower(ext) { - case ".woff2": - mimeType = "font/woff2" - case ".ttf": - mimeType = "font/ttf" - case ".otf": - mimeType = "font/otf" - default: - mimeType = "application/octet-stream" - } - } - - return data, mimeType, nil -} diff --git a/backend/internal/services/git_repository_service.go b/backend/internal/services/git_repository_service.go index e17d693c71..ec9c17302b 100644 --- a/backend/internal/services/git_repository_service.go +++ b/backend/internal/services/git_repository_service.go @@ -10,11 +10,10 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" git "github.com/getarcaneapp/arcane/backend/pkg/gitutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" - libbuild "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils" - "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "github.com/getarcaneapp/arcane/types/gitops" "gorm.io/gorm" ) @@ -39,25 +38,14 @@ func (s *GitRepositoryService) GetRepositoriesPaginated(ctx context.Context, par var repositories []models.GitRepository q := s.db.WithContext(ctx).Model(&models.GitRepository{}) - if term := strings.TrimSpace(params.Search); term != "" { - searchPattern := "%" + term + "%" - q = q.Where( - "name LIKE ? OR url LIKE ? OR COALESCE(description, '') LIKE ?", - searchPattern, searchPattern, searchPattern, - ) - } + q = pagination.ApplyLikeSearch(q, params.Search, "name LIKE ? OR url LIKE ? OR COALESCE(description, '') LIKE ?") q = pagination.ApplyBooleanFilter(q, "enabled", params.Filters["enabled"]) q = pagination.ApplyFilter(q, "auth_type", params.Filters["authType"]) - paginationResp, err := pagination.PaginateAndSortDB(params, q, &repositories) + out, paginationResp, err := pagination.PaginateSortAndMapDB[models.GitRepository, gitops.GitRepository](params, q, &repositories) if err != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to paginate git repositories: %w", err) - } - - out, mapErr := mapper.MapSlice[models.GitRepository, gitops.GitRepository](repositories) - if mapErr != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to map repositories: %w", mapErr) + return nil, pagination.Response{}, fmt.Errorf("failed to list git repositories: %w", err) } return out, paginationResp, nil @@ -67,7 +55,7 @@ func (s *GitRepositoryService) GetRepositoryByID(ctx context.Context, id string) var repository models.GitRepository if err := s.db.WithContext(ctx).Where("id = ?", id).First(&repository).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("repository not found") + return nil, errors.New("repository not found") } return nil, fmt.Errorf("failed to get repository: %w", err) } @@ -78,7 +66,7 @@ func (s *GitRepositoryService) GetRepositoryByName(ctx context.Context, name str var repository models.GitRepository if err := s.db.WithContext(ctx).Where("name = ?", name).First(&repository).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("repository not found") + return nil, errors.New("repository not found") } return nil, fmt.Errorf("failed to get repository: %w", err) } @@ -280,14 +268,11 @@ func validateGitRepositoryCredentialChangeInternal(repository *models.GitReposit if len(missingFields) == 1 { field := missingFields[0] - return &models.ValidationError{Field: field, Message: fmt.Sprintf("Changing repository URL requires re-supplying or clearing the %s", missingCredentialLabels[0])} + return &models.ValidationError{Field: field, Message: "Changing repository URL requires re-supplying or clearing the " + missingCredentialLabels[0]} } return models.NewValidationError( - fmt.Sprintf( - "Changing repository URL requires re-supplying or clearing all stored credentials: %s", - strings.Join(missingCredentialLabels, " and "), - ), + "Changing repository URL requires re-supplying or clearing all stored credentials: "+strings.Join(missingCredentialLabels, " and "), map[string]any{"fields": missingFields}, ) } diff --git a/backend/internal/services/git_repository_service_test.go b/backend/internal/services/git_repository_service_test.go index 007dcb4ec9..824707d774 100644 --- a/backend/internal/services/git_repository_service_test.go +++ b/backend/internal/services/git_repository_service_test.go @@ -75,7 +75,7 @@ func TestGitRepositoryService_UpdateRepository_RejectsURLChangeWhenStoredTokenWo }) _, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://attacker.tld/repo.git"), + URL: new("https://attacker.tld/repo.git"), }, models.User{}) require.Error(t, err) @@ -100,7 +100,7 @@ func TestGitRepositoryService_UpdateRepository_RejectsURLChangeWhenStoredSSHKeyW }) _, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("git@attacker.tld:acme/private.git"), + URL: new("git@attacker.tld:acme/private.git"), }, models.User{}) require.Error(t, err) @@ -127,7 +127,7 @@ func TestGitRepositoryService_UpdateRepository_RejectsURLChangeWhenStoredTokenAn }) _, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://attacker.tld/repo.git"), + URL: new("https://attacker.tld/repo.git"), }, models.User{}) require.Error(t, err) @@ -149,8 +149,8 @@ func TestGitRepositoryService_UpdateRepository_AllowsURLChangeWhenTokenIsResuppl }) updated, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://github.com/acme/private-rotated.git"), - Token: gitRepositoryStringPtrInternal("ghp_new_token"), + URL: new("https://github.com/acme/private-rotated.git"), + Token: new("ghp_new_token"), }, models.User{}) require.NoError(t, err) @@ -171,8 +171,8 @@ func TestGitRepositoryService_UpdateRepository_AllowsURLChangeWhenTokenIsCleared }) updated, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://github.com/acme/public.git"), - Token: gitRepositoryStringPtrInternal(""), + URL: new("https://github.com/acme/public.git"), + Token: new(""), }, models.User{}) require.NoError(t, err) @@ -191,8 +191,8 @@ func TestGitRepositoryService_UpdateRepository_AllowsSameURLWithoutCredentialRes }) updated, err := svc.UpdateRepository(context.Background(), repo.ID, models.UpdateGitRepositoryRequest{ - URL: gitRepositoryStringPtrInternal("https://github.com/acme/private.git"), - Username: gitRepositoryStringPtrInternal("deploy-bot"), + URL: new("https://github.com/acme/private.git"), + Username: new("deploy-bot"), }, models.User{}) require.NoError(t, err) diff --git a/backend/internal/services/gitops_sync_service.go b/backend/internal/services/gitops_sync_service.go index 1ed9d51177..4daeecf19a 100644 --- a/backend/internal/services/gitops_sync_service.go +++ b/backend/internal/services/gitops_sync_service.go @@ -8,6 +8,7 @@ import ( "log/slog" "maps" "os" + "path" "path/filepath" "strings" "time" @@ -15,7 +16,6 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" - "github.com/getarcaneapp/arcane/backend/pkg/libarcane/startup" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/projects" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -74,17 +74,207 @@ type stagedDirectorySync struct { func validateSyncLimits(maxFiles *int, maxTotalSize, maxBinarySize *int64) error { if maxFiles != nil && *maxFiles < 0 { - return fmt.Errorf("maxSyncFiles must be non-negative") + return errors.New("maxSyncFiles must be non-negative") } if maxTotalSize != nil && *maxTotalSize < 0 { - return fmt.Errorf("maxSyncTotalSize must be non-negative") + return errors.New("maxSyncTotalSize must be non-negative") } if maxBinarySize != nil && *maxBinarySize < 0 { - return fmt.Errorf("maxSyncBinarySize must be non-negative") + return errors.New("maxSyncBinarySize must be non-negative") } return nil } +// lifecycleConfigInputInternal is the slice of CreateSyncRequest / +// UpdateSyncRequest fields relevant to the pre-deploy lifecycle hook. Both +// request types collapse into the same shape so a single validator handles +// both flows. +type lifecycleConfigInputInternal struct { + scriptPath *string + runnerImage *string + env *string + extraMounts *string + timeoutSec *int + networkMode *string + syncDirectory *bool +} + +func (s *GitOpsSyncService) validateLifecycleConfigInternal(ctx context.Context, current *models.GitOpsSync, in lifecycleConfigInputInternal) error { + lifecycleFieldSet := in.scriptPath != nil || in.runnerImage != nil || in.env != nil || in.extraMounts != nil || in.timeoutSec != nil || in.networkMode != nil + syncDirectoryChanging := in.syncDirectory != nil + effectiveScriptPath := resolveLifecycleEffectiveStringInternal(currentStringInternal(current, func(c *models.GitOpsSync) *string { return c.PreDeployScriptPath }), in.scriptPath) + + // If nothing lifecycle-related is being touched and the resulting state + // has no script configured, there's nothing to validate. + if !lifecycleFieldSet && (!syncDirectoryChanging || effectiveScriptPath == "") { + return nil + } + + // Kill switch only blocks updates that actively touch lifecycle fields; + // toggling unrelated fields on an existing config shouldn't be rejected + // just because the global setting was turned off afterwards. + if lifecycleFieldSet && !s.settingsService.GetBoolSetting(ctx, "lifecycleEnabled", false) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy lifecycle hooks are disabled. An admin must enable lifecycleEnabled in settings before they can be configured."} + } + + if effectiveScriptPath != "" { + if len(effectiveScriptPath) > 256 { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be 256 characters or fewer."} + } + // scriptPath is a POSIX repo path, not a host path; use path.IsAbs so the + // check behaves the same on Windows-based contributor machines. + if path.IsAbs(filepath.ToSlash(effectiveScriptPath)) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must be relative to the project directory."} + } + cleaned := filepath.ToSlash(filepath.Clean(effectiveScriptPath)) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Script path must not escape the project directory."} + } + + effectiveRunnerImage := resolveLifecycleEffectiveStringInternal(currentStringInternal(current, func(c *models.GitOpsSync) *string { return c.PreDeployRunnerImage }), in.runnerImage) + if effectiveRunnerImage == "" { + return &models.ValidationError{Field: "preDeployRunnerImage", Message: "Runner image is required when a script path is set."} + } + + if !resolveEffectiveSyncDirectoryInternal(current, in.syncDirectory) { + return &models.ValidationError{Field: "preDeployScriptPath", Message: "Pre-deploy script requires \"Sync entire directory\" so the script is included in the synced files."} + } + } + + if in.timeoutSec != nil { + if *in.timeoutSec < 1 { + return &models.ValidationError{Field: "preDeployTimeoutSec", Message: "Timeout must be at least 1 second."} + } + maxTimeoutSec := s.settingsService.GetIntSetting(ctx, "lifecycleMaxTimeoutSec", lifecycleDefaultMaxTimeoutSec) + if maxTimeoutSec > 0 && *in.timeoutSec > maxTimeoutSec { + return &models.ValidationError{Field: "preDeployTimeoutSec", Message: fmt.Sprintf("Timeout %ds exceeds the lifecycleMaxTimeoutSec setting (%ds).", *in.timeoutSec, maxTimeoutSec)} + } + } + + if _, err := parseLifecycleEnvTextInternal(in.env); err != nil { + return &models.ValidationError{Field: "preDeployEnv", Message: err.Error()} + } + if _, err := parseLifecycleExtraMountsTextInternal(in.extraMounts); err != nil { + return &models.ValidationError{Field: "preDeployExtraMounts", Message: err.Error()} + } + + return nil +} + +// resolveLifecycleEffectiveStringInternal computes the value a string field +// will take after an update: a nil update leaves the existing value, a +// non-nil update (including empty string, which clears) overrides it. Used +// to validate the post-update state of a sync record. +func resolveLifecycleEffectiveStringInternal(existing string, update *string) string { + if update != nil { + return strings.TrimSpace(*update) + } + return strings.TrimSpace(existing) +} + +func currentStringInternal(sync *models.GitOpsSync, accessor func(*models.GitOpsSync) *string) string { + if sync == nil { + return "" + } + if p := accessor(sync); p != nil { + return *p + } + return "" +} + +// resolveEffectiveSyncDirectoryInternal mirrors the string resolver but for +// the SyncDirectory bool: a nil update keeps the existing value, a non-nil +// update overrides it. On create (current=nil) and no update, defaults to +// false to match the model's create-time default. +func resolveEffectiveSyncDirectoryInternal(current *models.GitOpsSync, update *bool) bool { + if update != nil { + return *update + } + if current != nil { + return current.SyncDirectory + } + return false +} + +// applyLifecycleFieldsToSyncInternal copies lifecycle config from a Create +// request into a new GitOpsSync row before insert. Strings are trimmed; +// empty strings remain unset (nil pointer) except for fields that have a +// non-null default at the DB level. +func applyLifecycleFieldsToSyncInternal(sync *models.GitOpsSync, in lifecycleConfigInputInternal) { + sync.PreDeployScriptPath = nullableTrimmedStringInternal(in.scriptPath) + sync.PreDeployRunnerImage = nullableTrimmedStringInternal(in.runnerImage) + sync.PreDeployEnv = nullableTrimmedStringInternal(in.env) + sync.PreDeployExtraMounts = nullableTrimmedStringInternal(in.extraMounts) + if in.timeoutSec != nil { + sync.PreDeployTimeoutSec = *in.timeoutSec + } + if mode := normalizeLifecycleNetworkModeInternal(in.networkMode); mode != "" { + sync.PreDeployNetworkMode = mode + } +} + +// addLifecycleUpdatesInternal appends lifecycle field updates to the GORM +// updates map. nil pointers leave the field unchanged; non-nil empty strings +// clear nullable fields to NULL and reset fixed-default fields to their +// default value. +func addLifecycleUpdatesInternal(updates map[string]any, in lifecycleConfigInputInternal) { + if in.scriptPath != nil { + updates["pre_deploy_script_path"] = nullableUpdateStringValueInternal(in.scriptPath) + } + if in.runnerImage != nil { + updates["pre_deploy_runner_image"] = nullableUpdateStringValueInternal(in.runnerImage) + } + if in.env != nil { + updates["pre_deploy_env"] = nullableUpdateStringValueInternal(in.env) + } + if in.extraMounts != nil { + updates["pre_deploy_extra_mounts"] = nullableUpdateStringValueInternal(in.extraMounts) + } + if in.timeoutSec != nil { + updates["pre_deploy_timeout_sec"] = *in.timeoutSec + } + if in.networkMode != nil { + updates["pre_deploy_network_mode"] = normalizeLifecycleNetworkModeInternal(in.networkMode) + } +} + +// normalizeLifecycleNetworkModeInternal returns the canonical network mode +// string for a user-supplied value. Empty / whitespace / unset all map to +// "none" so the secure-by-default behaviour is restored when the user clears +// the field. +func normalizeLifecycleNetworkModeInternal(p *string) string { + if p == nil { + return "none" + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return "none" + } + return trimmed +} + +func nullableTrimmedStringInternal(p *string) *string { + if p == nil { + return nil + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return nil + } + return &trimmed +} + +func nullableUpdateStringValueInternal(p *string) any { + if p == nil { + return nil + } + trimmed := strings.TrimSpace(*p) + if trimmed == "" { + return nil + } + return trimmed +} + func normalizeSyncLimitSetting(value, defaultValue int) int { if value < 0 { return defaultValue @@ -147,42 +337,6 @@ func (s *GitOpsSyncService) gitSyncLimitEnvOverrideActiveInternal(key string) bo return s.settingsService != nil && s.settingsService.isEnvOverrideActiveInternal(key) } -func (s *GitOpsSyncService) ListSyncIntervalsRaw(ctx context.Context) ([]startup.IntervalMigrationItem, error) { - rows, err := s.db.WithContext(ctx).Raw("SELECT id, sync_interval FROM gitops_syncs").Rows() - if err != nil { - return nil, fmt.Errorf("failed to load git sync intervals: %w", err) - } - defer func() { _ = rows.Close() }() - - items := make([]startup.IntervalMigrationItem, 0) - for rows.Next() { - var id string - var raw any - if err := rows.Scan(&id, &raw); err != nil { - return nil, fmt.Errorf("failed to scan git sync interval: %w", err) - } - items = append(items, startup.IntervalMigrationItem{ - ID: id, - RawValue: strings.TrimSpace(fmt.Sprint(raw)), - }) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("failed to read git sync intervals: %w", err) - } - - return items, nil -} - -func (s *GitOpsSyncService) UpdateSyncIntervalMinutes(ctx context.Context, id string, minutes int) error { - if minutes <= 0 { - return fmt.Errorf("sync interval must be positive") - } - return s.db.WithContext(ctx). - Model(&models.GitOpsSync{}). - Where("id = ?", id). - Update("sync_interval", minutes).Error -} - func (s *GitOpsSyncService) GetSyncsPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]gitops.GitOpsSync, pagination.Response, gitops.SyncCounts, error) { var syncs []models.GitOpsSync q := s.db.WithContext(ctx).Model(&models.GitOpsSync{}). @@ -251,7 +405,7 @@ func (s *GitOpsSyncService) GetSyncByID(ctx context.Context, environmentID, id s if err := q.First(&sync).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { slog.WarnContext(ctx, "GitOps sync not found", "syncID", id, "environmentID", environmentID) - return nil, fmt.Errorf("sync not found") + return nil, errors.New("sync not found") } slog.ErrorContext(ctx, "Failed to get GitOps sync", "syncID", id, "environmentID", environmentID, "error", err) return nil, fmt.Errorf("failed to get sync: %w", err) @@ -317,7 +471,23 @@ func (s *GitOpsSyncService) CreateSync(ctx context.Context, environmentID string sync.MaxSyncBinarySize = *req.MaxSyncBinarySize } - if err := s.db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { + lifecycleCfg := lifecycleConfigInputInternal{ + scriptPath: req.PreDeployScriptPath, + runnerImage: req.PreDeployRunnerImage, + env: req.PreDeployEnv, + extraMounts: req.PreDeployExtraMounts, + timeoutSec: req.PreDeployTimeoutSec, + networkMode: req.PreDeployNetworkMode, + syncDirectory: req.SyncDirectory, + } + if err := s.validateLifecycleConfigInternal(ctx, nil, lifecycleCfg); err != nil { + return nil, err + } + applyLifecycleFieldsToSyncInternal(&sync, lifecycleCfg) + + // Select("*") forces explicit zero values (e.g. "0 = unlimited" sync limits and + // unset pre-deploy fields) to persist instead of GORM substituting column defaults. + if err := s.db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(&sync).Error; err != nil { //nolint:unqueryvet // intentional Select("*"); see comment above slog.ErrorContext(ctx, "Failed to create GitOps sync in database", "name", req.Name, "repositoryID", req.RepositoryID, "environmentID", environmentID, "error", err) return nil, fmt.Errorf("failed to create sync: %w", err) } @@ -398,6 +568,20 @@ func (s *GitOpsSyncService) UpdateSync(ctx context.Context, environmentID, id st updates["max_sync_binary_size"] = *req.MaxSyncBinarySize } + lifecycleCfg := lifecycleConfigInputInternal{ + scriptPath: req.PreDeployScriptPath, + runnerImage: req.PreDeployRunnerImage, + env: req.PreDeployEnv, + extraMounts: req.PreDeployExtraMounts, + timeoutSec: req.PreDeployTimeoutSec, + networkMode: req.PreDeployNetworkMode, + syncDirectory: req.SyncDirectory, + } + if err := s.validateLifecycleConfigInternal(ctx, sync, lifecycleCfg); err != nil { + return nil, err + } + addLifecycleUpdatesInternal(updates, lifecycleCfg) + if len(updates) > 0 { if err := s.db.WithContext(ctx).Model(sync).Updates(updates).Error; err != nil { return nil, fmt.Errorf("failed to update sync: %w", err) @@ -525,8 +709,8 @@ func (s *GitOpsSyncService) prepareSyncSource(ctx context.Context, sync *models. } if !s.repoService.gitClient.FileExists(ctx, repoPath, sync.ComposePath) { - errMsg := fmt.Sprintf("compose file not found: %s", sync.ComposePath) - return &preparedSyncSource{repoPath: repoPath, commitHash: commitHash}, s.failSync(ctx, sync.ID, result, sync, actor, fmt.Sprintf("Compose file not found at %s", sync.ComposePath), errMsg) + errMsg := "compose file not found: " + sync.ComposePath + return &preparedSyncSource{repoPath: repoPath, commitHash: commitHash}, s.failSync(ctx, sync.ID, result, sync, actor, "Compose file not found at "+sync.ComposePath, errMsg) } composeContent, err := s.repoService.gitClient.ReadFile(ctx, repoPath, sync.ComposePath) @@ -558,7 +742,7 @@ func (s *GitOpsSyncService) prepareSyncSource(ctx context.Context, sync *models. func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *models.GitOpsSync, id string, actor models.User, result *gitops.SyncResult, source *preparedSyncSource) (*gitops.SyncResult, error) { slog.InfoContext(ctx, "Using directory sync mode", "syncId", id, "composePath", sync.ComposePath) - _, syncFiles, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) + syncFiles, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) if err != nil { return result, s.failSync(ctx, id, result, sync, actor, "Failed to walk directory", err.Error()) } @@ -569,7 +753,14 @@ func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *mode } if contentsChanged { - s.redeployIfRunningAfterSync(ctx, project, actor, "directory") + if redeployErr := s.redeployIfRunningAfterSync(ctx, project, actor, "directory"); redeployErr != nil { + // redeployIfRunningAfterSync only ever returns *common.RedeployAfterSyncFailedError; + // AsType remains as a defensive guard against future contract drift. + if typed, ok := errors.AsType[*common.RedeployAfterSyncFailedError](redeployErr); ok { + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, typed, actor, result) + } + return result, redeployErr + } } s.updateSyncStatusWithFiles(ctx, id, "success", "", source.commitHash, syncedFiles) @@ -587,6 +778,14 @@ func (s *GitOpsSyncService) performSingleFileSyncInternal(ctx context.Context, s project, err := s.getOrCreateProjectInternal(ctx, sync, id, source.composeContent, source.envContent, result, actor) if err != nil { + // err may be a *common.RedeployAfterSyncFailedError (from updateProjectForSyncInternal) + // or a plain failSync error (from CreateProject / ApplyGitSyncProjectFiles). + // Only the typed case warrants the redeploy-failure marker; the rest just + // surface through the caller. + if redeployErr, ok := errors.AsType[*common.RedeployAfterSyncFailedError](err); ok { + syncedFiles := []string{filepath.Base(sync.ComposePath)} + s.markSyncRedeployFailedInternal(ctx, sync, id, source.commitHash, syncedFiles, redeployErr, actor, result) + } return result, err } @@ -615,7 +814,7 @@ func (s *GitOpsSyncService) performSwarmStackSyncInternal(ctx context.Context, s var syncFiles []projects.SyncFile if sync.SyncDirectory { - _, files, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) + files, err := s.walkAndParseSyncDirectory(ctx, sync, source.repoPath) if err != nil { return result, s.failSync(ctx, id, result, sync, actor, "Failed to walk directory", err.Error()) } @@ -671,20 +870,24 @@ func (s *GitOpsSyncService) performSwarmStackSyncInternal(ctx context.Context, s } // redeployIfRunningAfterSync redeploys a project only when it is already -// running and the latest sync actually changed managed content. -func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) { +// running and the latest sync actually changed managed content. Returns a +// *common.RedeployAfterSyncFailedError when the redeploy itself fails; callers +// surface that on the sync row's LastSyncError. +func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) error { details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails()) if err != nil { - return + return nil //nolint:nilerr // best-effort: skip post-sync redeploy when project state can't be determined } if details.Status != string(models.ProjectStatusRunning) && details.Status != string(models.ProjectStatusPartiallyRunning) { - return + return nil } slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "syncMode", syncMode, "projectName", project.Name, "projectId", project.ID) if err := s.projectService.RedeployProject(ctx, project.ID, actor); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "syncMode", syncMode, "error", err, "projectId", project.ID) + return &common.RedeployAfterSyncFailedError{Err: err} } + return nil } // logSyncSuccess records the Git sync completion event once the filesystem and @@ -826,7 +1029,7 @@ func (s *GitOpsSyncService) BrowseFiles(ctx context.Context, environmentID, id s repository := sync.Repository if repository == nil { - return nil, fmt.Errorf("repository not found") + return nil, errors.New("repository not found") } authConfig, err := s.repoService.GetAuthConfig(browseCtx, repository) @@ -922,6 +1125,20 @@ func (s *GitOpsSyncService) failSync(ctx context.Context, id string, result *git return fmt.Errorf("%s", errMsg) } +// markSyncRedeployFailedInternal records a sync where the file sync wrote +// cleanly but the auto-redeploy that follows failed (typically a pre-deploy +// lifecycle hook returning non-zero). The synced-files list and commit hash +// are preserved on the row so operators can see what reached disk; the +// error message surfaces the redeploy failure on LastSyncError. +func (s *GitOpsSyncService) markSyncRedeployFailedInternal(ctx context.Context, sync *models.GitOpsSync, id, commitHash string, syncedFiles []string, redeployErr *common.RedeployAfterSyncFailedError, actor models.User, result *gitops.SyncResult) { + errMsg := redeployErr.Error() + result.Success = false + result.Message = "Sync wrote files but redeploy failed" + result.Error = new(errMsg) + s.updateSyncStatusWithFiles(ctx, id, "failed", errMsg, commitHash, syncedFiles) + s.logSyncError(ctx, sync, actor, errMsg) +} + func (s *GitOpsSyncService) createProjectForSyncInternal(ctx context.Context, sync *models.GitOpsSync, id string, composeContent string, envContent *string, result *gitops.SyncResult, actor models.User) (*models.Project, error) { project, err := s.projectService.CreateProject(ctx, sync.ProjectName, composeContent, envContent, actor) if err != nil { @@ -989,13 +1206,16 @@ func (s *GitOpsSyncService) updateProjectForSyncInternal(ctx context.Context, sy newCompose, newEnv, _ := s.projectService.GetProjectContent(ctx, project.ID) contentChanged := oldCompose != newCompose || envContentChangedInternal(oldEnv, newEnv) - // If content changed and project is running, redeploy + // If content changed and project is running, redeploy. A redeploy failure + // is bubbled up as a *common.RedeployAfterSyncFailedError so the parent flow + // can reflect it on the sync row's LastSyncError. if contentChanged { details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails()) if err == nil && (details.Status == string(models.ProjectStatusRunning) || details.Status == string(models.ProjectStatusPartiallyRunning)) { slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "projectName", project.Name, "projectId", project.ID) if err := s.projectService.RedeployProject(ctx, project.ID, actor); err != nil { slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "error", err, "projectId", project.ID) + return &common.RedeployAfterSyncFailedError{Err: err} } } } @@ -1038,8 +1258,8 @@ func marshalSyncedFiles(files []string) *string { } // walkAndParseSyncDirectory walks the repository directory and returns all files with their contents. -// Returns the compose file content, the list of SyncFile entries, and an error if any. -func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync *models.GitOpsSync, repoPath string) (string, []projects.SyncFile, error) { +// Returns the list of SyncFile entries and an error if any; it fails if the compose file is missing. +func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync *models.GitOpsSync, repoPath string) ([]projects.SyncFile, error) { slog.InfoContext(ctx, "Starting directory walk", "syncId", sync.ID, "composePath", sync.ComposePath) // Walk the directory to get all files @@ -1047,7 +1267,7 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync walkResult, err := s.repoService.gitClient.WalkDirectory(ctx, repoPath, sync.ComposePath, maxFiles, maxTotalSize, maxBinarySize) if err != nil { - return "", nil, fmt.Errorf("failed to walk directory: %w", err) + return nil, fmt.Errorf("failed to walk directory: %w", err) } slog.InfoContext(ctx, "Directory walk complete", @@ -1059,7 +1279,7 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync // WalkDirectory roots the walk at filepath.Dir(sync.ComposePath), so the // compose file is always emitted at the top level as filepath.Base(sync.ComposePath). composeFileName := filepath.Base(sync.ComposePath) - var composeContent string + composeFound := false // Convert walked files to SyncFile format syncFiles := make([]projects.SyncFile, len(walkResult.Files)) @@ -1067,17 +1287,18 @@ func (s *GitOpsSyncService) walkAndParseSyncDirectory(ctx context.Context, sync syncFiles[i] = projects.SyncFile{ RelativePath: f.RelativePath, Content: f.Content, + Executable: f.Executable, } if f.RelativePath == composeFileName { - composeContent = string(f.Content) + composeFound = true } } - if composeContent == "" { - return "", nil, fmt.Errorf("compose file %s not found in walked directory", composeFileName) + if !composeFound { + return nil, fmt.Errorf("compose file %s not found in walked directory", composeFileName) } - return composeContent, syncFiles, nil + return syncFiles, nil } // syncProjectDirectoryInternal runs the new directory-sync path end to end: @@ -1426,14 +1647,12 @@ func (s *GitOpsSyncService) findUniqueProjectDirectoryCandidateInternal(ctx cont } func (s *GitOpsSyncService) createRecoveredProjectFromDirectoryInternal(ctx context.Context, sync *models.GitOpsSync, projectPath string) (*models.Project, error) { - dirName := filepath.Base(projectPath) - reason := "Project recovered from existing GitOps-managed directory" project := &models.Project{ Name: sync.ProjectName, - DirName: &dirName, + DirName: new(filepath.Base(projectPath)), Path: projectPath, Status: models.ProjectStatusUnknown, - StatusReason: &reason, + StatusReason: new("Project recovered from existing GitOps-managed directory"), ServiceCount: 0, RunningCount: 0, GitOpsManagedBy: &sync.ID, @@ -1544,10 +1763,7 @@ func (s *GitOpsSyncService) validateDirectorySyncStageInternal(ctx context.Conte return 0, err } - pathMapper, pmErr := s.projectService.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper for directory sync validation, continuing without translation", "error", pmErr) - } + pathMapper := s.projectService.getPathMapper(ctx) autoInjectEnv := s.settingsService.GetBoolSetting(ctx, "autoInjectEnv", false) project, err := projects.LoadComposeProjectLenient( diff --git a/backend/internal/services/gitops_sync_service_test.go b/backend/internal/services/gitops_sync_service_test.go index 9ea51479ba..374dc37edd 100644 --- a/backend/internal/services/gitops_sync_service_test.go +++ b/backend/internal/services/gitops_sync_service_test.go @@ -8,11 +8,13 @@ import ( "path/filepath" "testing" + "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" git "github.com/getarcaneapp/arcane/backend/pkg/gitutil" "github.com/getarcaneapp/arcane/backend/pkg/projects" + "github.com/getarcaneapp/arcane/types/gitops" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -31,7 +33,7 @@ func setupGitOpsSyncDirectoryTestService(t *testing.T) (*GitOpsSyncService, *dat projectsDir := t.TempDir() require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) - projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) return NewGitOpsSyncService(db, nil, projectService, nil, nil, settingsService), db, projectsDir } @@ -232,8 +234,14 @@ func TestGitOpsSyncService_DirectorySync_RealWalkWithNestedConfig(t *testing.T) } require.NoError(t, db.Create(sync).Error) - composeContent, syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) + syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) require.NoError(t, err) + var composeContent string + for _, f := range syncFiles { + if f.RelativePath == "docker-compose.yml" { + composeContent = string(f.Content) + } + } assert.Contains(t, composeContent, "./config/dynamic_config.yml") project, syncedFiles, created, changed, err := svc.syncProjectDirectoryInternal(ctx, sync, syncFiles, models.User{}) @@ -307,7 +315,7 @@ func TestGitOpsSyncService_DirectorySync_OverwritesExistingDirectoryAtFilePath(t } require.NoError(t, db.Create(sync).Error) - _, syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) + syncFiles, err := svc.walkAndParseSyncDirectory(ctx, sync, repoPath) require.NoError(t, err) updatedProject, syncedFiles, created, changed, err := svc.syncProjectDirectoryInternal(ctx, sync, syncFiles, models.User{}) @@ -659,3 +667,252 @@ func TestGitOpsSyncService_GetEffectiveSyncLimits(t *testing.T) { require.Equal(t, int64(0), maxBinarySize) }) } + +// setupLifecycleValidationService builds a GitOpsSyncService with lifecycle +// hooks enabled in settings so the validator's gate doesn't short-circuit +// the rule checks under test. +func setupLifecycleValidationService(t *testing.T) (*GitOpsSyncService, context.Context) { + t.Helper() + ctx := context.Background() + svc, _, _ := setupGitOpsSyncDirectoryTestService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleEnabled", "true")) + return svc, ctx +} + +func strPtr(s string) *string { return &s } +func intPtr(i int) *int { return &i } +func boolPtr(b bool) *bool { return &b } + +func TestValidateLifecycleConfig_AllNilNoError(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{})) +} + +func TestValidateLifecycleConfig_RejectsWhenGloballyDisabled(t *testing.T) { + svc, _, _ := setupGitOpsSyncDirectoryTestService(t) + ctx := context.Background() + // lifecycleEnabled defaults to false; do not enable. + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "disabled") +} + +func TestValidateLifecycleConfig_RejectsAbsoluteScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("/etc/passwd"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "relative") +} + +func TestValidateLifecycleConfig_RejectsTraversalScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("../outside.sh"), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "escape") +} + +func TestValidateLifecycleConfig_RejectsOverlongScriptPath(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + long := make([]byte, 257) + for i := range long { + long[i] = 'a' + } + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr(string(long)), + runnerImage: strPtr("alpine:latest"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "256") +} + +func TestValidateLifecycleConfig_RejectsScriptWithoutRunnerImageOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "Runner image is required") +} + +func TestValidateLifecycleConfig_AcceptsScriptWithExistingRunnerImageOnUpdate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + existing.PreDeployRunnerImage = strPtr("alpine:latest") + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + })) +} + +func TestValidateLifecycleConfig_RejectsTimeoutZeroOrNegative(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + for _, v := range []int{0, -1, -3600} { + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + timeoutSec: intPtr(v), + }) + require.Errorf(t, err, "expected error for timeoutSec=%d", v) + require.Contains(t, err.Error(), "at least 1") + } +} + +func TestValidateLifecycleConfig_RejectsTimeoutAboveSettingCap(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.settingsService.SetStringSetting(ctx, "lifecycleMaxTimeoutSec", "120")) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + timeoutSec: intPtr(300), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds") +} + +func TestValidateLifecycleConfig_RejectsInvalidEnvKey(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + env: strPtr("FOO-BAR=baz"), + }) + require.Error(t, err) +} + +func TestValidateLifecycleConfig_AcceptsValidEnv(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + env: strPtr("FOO=bar\nBAZ_2=qux"), + })) +} + +func TestValidateLifecycleConfig_RejectsRelativeMountSource(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + extraMounts: strPtr("relative/path:/in/container"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "absolute") +} + +func TestValidateLifecycleConfig_RejectsRelativeMountTarget(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + extraMounts: strPtr("/host/path:relative/target"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "absolute") +} + +func TestValidateLifecycleConfig_AllowsClearingScriptWithoutImage(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{} + existing.PreDeployScriptPath = strPtr("scripts/old.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + // User clears the script (empty string in update); image clear is implied not required. + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr(""), + })) +} + +func TestValidateLifecycleConfig_RejectsScriptWithoutSyncDirectoryOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + err := svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(false), + }) + require.Error(t, err) + validationErr, ok := errors.AsType[*models.ValidationError](err) + require.True(t, ok, "expected *models.ValidationError, got %T", err) + require.Equal(t, "preDeployScriptPath", validationErr.Field) +} + +func TestValidateLifecycleConfig_AcceptsScriptWithSyncDirectoryOnCreate(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, nil, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + syncDirectory: boolPtr(true), + })) +} + +func TestValidateLifecycleConfig_AcceptsScriptWhenExistingSyncHasSyncDirectory(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + require.NoError(t, svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + scriptPath: strPtr("scripts/deploy.sh"), + runnerImage: strPtr("alpine:latest"), + })) +} + +func TestValidateLifecycleConfig_RejectsSyncDirectoryToggleOffWhileScriptStillSet(t *testing.T) { + svc, ctx := setupLifecycleValidationService(t) + existing := &models.GitOpsSync{SyncDirectory: true} + existing.PreDeployScriptPath = strPtr("scripts/deploy.sh") + existing.PreDeployRunnerImage = strPtr("alpine:latest") + // Admin toggles syncDirectory off without clearing the script — should be rejected. + err := svc.validateLifecycleConfigInternal(ctx, existing, lifecycleConfigInputInternal{ + syncDirectory: boolPtr(false), + }) + require.Error(t, err) + validationErr, ok := errors.AsType[*models.ValidationError](err) + require.True(t, ok, "expected *models.ValidationError, got %T", err) + require.Equal(t, "preDeployScriptPath", validationErr.Field) +} + +func TestRedeployAfterSyncFailedError_FormatAndUnwrap(t *testing.T) { + cause := errors.New("pre-deploy hook bombed") + err := &common.RedeployAfterSyncFailedError{Err: cause} + + require.Equal(t, "redeploy failed: pre-deploy hook bombed", err.Error()) + require.True(t, errors.Is(err, cause), "Unwrap should expose the cause for errors.Is") + + typed, ok := errors.AsType[*common.RedeployAfterSyncFailedError](err) + require.True(t, ok) + require.Equal(t, cause, typed.Err) +} + +func TestMarkSyncRedeployFailedInternal_PersistsErrorOnSyncRow(t *testing.T) { + ctx := context.Background() + svc, db, _ := setupGitOpsSyncDirectoryTestService(t) + // Event logging requires a real EventService; the shared setup leaves it + // nil since most tests don't exercise the event path. + require.NoError(t, db.AutoMigrate(&models.Event{})) + svc.eventService = NewEventService(db, config.Load(), nil) + + sync := &models.GitOpsSync{ + BaseModel: models.BaseModel{ID: "sync-1"}, + Name: "redeploy-fail", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "compose.yml", + TargetType: "project", + } + require.NoError(t, db.WithContext(ctx).Select("*").Omit("Environment", "Repository", "Project").Create(sync).Error) + + result := &gitops.SyncResult{Success: true} + syncedFiles := []string{"compose.yml", "scripts/pre-deploy.sh"} + hookErr := &common.RedeployAfterSyncFailedError{Err: errors.New("pre-deploy hook failed: exit 1")} + + svc.markSyncRedeployFailedInternal(ctx, sync, sync.ID, "abc123", syncedFiles, hookErr, models.User{BaseModel: models.BaseModel{ID: "user"}, Username: "tester"}, result) + + require.False(t, result.Success) + require.NotNil(t, result.Error) + require.Contains(t, *result.Error, "pre-deploy hook failed") + + var stored models.GitOpsSync + require.NoError(t, db.WithContext(ctx).First(&stored, "id = ?", sync.ID).Error) + require.NotNil(t, stored.LastSyncStatus) + require.Equal(t, "failed", *stored.LastSyncStatus) + require.NotNil(t, stored.LastSyncError) + require.Contains(t, *stored.LastSyncError, "pre-deploy hook failed") + // The synced-files list should still be populated so operators can see + // what reached disk before the redeploy died. + require.NotNil(t, stored.SyncedFiles) + require.Contains(t, *stored.SyncedFiles, "compose.yml") + require.Contains(t, *stored.SyncedFiles, "scripts/pre-deploy.sh") +} diff --git a/backend/internal/services/image_service.go b/backend/internal/services/image_service.go index 6734c1cb0a..2adafacc9b 100644 --- a/backend/internal/services/image_service.go +++ b/backend/internal/services/image_service.go @@ -11,6 +11,7 @@ import ( "sync" "time" + ref "github.com/distribution/reference" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" @@ -23,7 +24,6 @@ import ( "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/image" "github.com/moby/moby/client" - ref "go.podman.io/image/v5/docker/reference" "golang.org/x/sync/errgroup" "gorm.io/gorm" ) @@ -401,14 +401,14 @@ func (s *ImageService) PruneImages(ctx context.Context, options systemtypes.Prun filterArgs := make(client.Filters) switch options.Mode { case systemtypes.PruneImageModeNone: - return nil, fmt.Errorf("image prune mode none is not allowed") + return nil, errors.New("image prune mode none is not allowed") case systemtypes.PruneImageModeDangling: filterArgs = filterArgs.Add("dangling", "true") case systemtypes.PruneImageModeAll: filterArgs = filterArgs.Add("dangling", "false") case systemtypes.PruneImageModeOlderThan: if strings.TrimSpace(options.Until) == "" { - return nil, fmt.Errorf("image prune mode olderThan requires until") + return nil, errors.New("image prune mode olderThan requires until") } filterArgs = filterArgs.Add("until", options.Until) default: @@ -570,11 +570,6 @@ func (s *ImageService) GetUpdateInfoByImageRefs(ctx context.Context, imageRefs [ } func (s *ImageService) ListImagesPaginated(ctx context.Context, params pagination.QueryParams) ([]imagetypes.Summary, pagination.Response, error) { - dockerClient, err := s.dockerService.GetClient(ctx) - if err != nil { - return nil, pagination.Response{}, fmt.Errorf("failed to connect to Docker: %w", err) - } - var ( dockerImages []image.Summary containers []container.Summary @@ -586,22 +581,22 @@ func (s *ImageService) ListImagesPaginated(ctx context.Context, params paginatio // Fetch Docker images g.Go(func() error { var err error - imageList, err := dockerClient.ImageList(groupCtx, client.ImageListOptions{}) + imageList, err := s.dockerService.listImagesInternal(groupCtx) if err != nil { return fmt.Errorf("failed to list Docker images: %w", err) } - dockerImages = imageList.Items + dockerImages = imageList return nil }) // Fetch containers to determine usage g.Go(func() error { var err error - containerList, err := dockerClient.ContainerList(groupCtx, client.ContainerListOptions{All: true}) + containerList, err := s.dockerService.listContainersInternal(groupCtx) if err != nil { return fmt.Errorf("failed to list containers: %w", err) } - containers = containerList.Items + containers = containerList return nil }) @@ -744,18 +739,13 @@ func convertLabels(labels map[string]string) map[string]any { } func (s *ImageService) GetTotalImageSize(ctx context.Context) (int64, error) { - dockerClient, err := s.dockerService.GetClient(ctx) - if err != nil { - return 0, fmt.Errorf("failed to connect to Docker: %w", err) - } - - imageList, err := dockerClient.ImageList(ctx, client.ImageListOptions{}) + images, err := s.dockerService.listImagesInternal(ctx) if err != nil { return 0, fmt.Errorf("failed to list images: %w", err) } var total int64 - for _, img := range imageList.Items { + for _, img := range images { total += img.Size } @@ -813,7 +803,7 @@ func (s *ImageService) BuildProjectIDMap(ctx context.Context, containers []conta if c.Labels == nil { continue } - if projectName := c.Labels["com.docker.compose.project"]; projectName != "" { + if projectName := dockerutils.ComposeProjectLabel(c.Labels); projectName != "" { projectNameSet[projectName] = struct{}{} } } @@ -840,10 +830,7 @@ func buildUsageMapInternal(containers []container.Summary, projectIDByName map[s continue } - projectName := "" - if c.Labels != nil { - projectName = c.Labels["com.docker.compose.project"] - } + projectName := dockerutils.ComposeProjectLabel(c.Labels) if projectName != "" { projectID := projectIDByName[projectName] diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index 74a199683d..b7f677c114 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "maps" @@ -9,17 +10,19 @@ import ( "sync" "time" + ref "github.com/distribution/reference" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/crypto" imageupdatecore "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/ratelimit" registry "github.com/getarcaneapp/arcane/backend/pkg/libarcane/registryauth" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/containerregistry" "github.com/getarcaneapp/arcane/types/imageupdate" "github.com/moby/moby/api/types/image" "github.com/moby/moby/client" - ref "go.podman.io/image/v5/docker/reference" "golang.org/x/sync/errgroup" "gorm.io/gorm" ) @@ -32,6 +35,7 @@ type ImageUpdateService struct { eventService *EventService notificationService *NotificationService registryLimiter *ratelimit.RegistryRateLimiter + activityService *ActivityService } type ImageParts struct { @@ -48,7 +52,7 @@ type localImageSnapshot struct { AllDigests []string } -func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService) *ImageUpdateService { +func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService, activityService *ActivityService) *ImageUpdateService { return &ImageUpdateService{ db: db, settingsService: settingsService, @@ -57,19 +61,93 @@ func NewImageUpdateService(db *database.DB, settingsService *SettingsService, re eventService: eventService, notificationService: notificationService, registryLimiter: ratelimit.NewRegistryRateLimiter(), + activityService: activityService, + } +} + +func (s *ImageUpdateService) startImageUpdateActivityInternal(ctx context.Context, resourceName string, count int) string { + if s.activityService == nil { + return "" + } + resourceType := "image" + if count > 1 { + resourceType = "images" + } + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeImageUpdateCheck, + ResourceType: &resourceType, + ResourceName: utils.StringPtrFromTrimmed(resourceName), + Step: "Checking image updates", + LatestMessage: "Image update check started", + Metadata: models.JSON{ + "imageCount": count, + }, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start image update activity", "error", err) + return "" + } + return activity.ID +} + +func (s *ImageUpdateService) appendImageUpdateActivityMessageInternal(ctx context.Context, activityID string, level models.ActivityMessageLevel, message string, progress int, step string) { + if s.activityService == nil || activityID == "" || strings.TrimSpace(message) == "" { + return + } + if level == "" { + level = models.ActivityMessageLevelInfo + } + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: level, + Message: message, + Progress: &progress, + Step: step, + }); err != nil { + slog.DebugContext(ctx, "failed to append image update activity message", "activityId", activityID, "error", err) + } +} + +func (s *ImageUpdateService) completeImageUpdateActivityInternal(ctx context.Context, activityID string, success bool, message string) { + if s.activityService == nil || activityID == "" { + return + } + status := models.ActivityStatusSuccess + var errMessage *string + if !success { + status = models.ActivityStatusFailed + errMessage = utils.StringPtrFromTrimmed(message) + if activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + errMessage = nil + message = "Image update check cancelled" + } + } + if message == "" { + message = "Image update check completed" + } + step := "Image update check complete" + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage, step); err != nil { + slog.DebugContext(ctx, "failed to complete image update activity", "activityId", activityID, "error", err) } } func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef string) (*imageupdate.Response, error) { startTime := time.Now() + activityID := s.startImageUpdateActivityInternal(ctx, imageRef, 1) + ctx = s.activityService.Track(ctx, activityID) + s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, "Checking "+imageRef, 20, "Checking remote digest") parts := s.parseImageReference(imageRef) if parts == nil { - return &imageupdate.Response{ + result := &imageupdate.Response{ Error: "Invalid image reference format", CheckTime: time.Now(), ResponseTimeMs: int(time.Since(startTime).Milliseconds()), - }, nil + ActivityID: utils.StringPtrFromTrimmed(activityID), + } + s.completeImageUpdateActivityInternal(ctx, activityID, false, result.Error) + return result, nil } digestResult, snapshot, err := s.checkDigestUpdateWithSnapshotInternal(ctx, parts) @@ -78,6 +156,7 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri Error: err.Error(), CheckTime: time.Now(), ResponseTimeMs: int(time.Since(startTime).Milliseconds()), + ActivityID: utils.StringPtrFromTrimmed(activityID), } metadata := models.JSON{ "action": "check_update", @@ -91,10 +170,12 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri if saveErr := s.saveUpdateResultWithSnapshotInternal(ctx, imageRef, result, snapshot); saveErr != nil { slog.WarnContext(ctx, "Failed to save update result", "imageRef", imageRef, "error", saveErr.Error()) } + s.completeImageUpdateActivityInternal(ctx, activityID, false, result.Error) return result, err } digestResult.ResponseTimeMs = int(time.Since(startTime).Milliseconds()) + digestResult.ActivityID = utils.StringPtrFromTrimmed(activityID) metadata := models.JSON{ "action": "check_update", "imageRef": imageRef, @@ -118,12 +199,17 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri } } + finalMessage := "Image update check completed" + if digestResult.HasUpdate { + finalMessage = "Image update available" + } + s.completeImageUpdateActivityInternal(ctx, activityID, true, finalMessage) return digestResult, nil } func (s *ImageUpdateService) checkDigestUpdateWithSnapshotInternal(ctx context.Context, parts *ImageParts) (*imageupdate.Response, *localImageSnapshot, error) { if s.registryService == nil { - return nil, nil, fmt.Errorf("registry service unavailable") + return nil, nil, errors.New("registry service unavailable") } imageRef := fmt.Sprintf("%s/%s:%s", parts.Registry, parts.Repository, parts.Tag) @@ -310,7 +396,7 @@ func (s *ImageUpdateService) resolveImageRefFromInspect(ctx context.Context, doc } } } - return "", fmt.Errorf("no valid tags or digests") + return "", errors.New("no valid tags or digests") } func (s *ImageUpdateService) resolveImageRefFromContainers(ctx context.Context, dockerClient client.APIClient, imageID string) (string, error) { @@ -449,7 +535,7 @@ func (s *ImageUpdateService) saveUpdateResultWithSnapshotInternal(ctx context.Co parts := s.parseImageReference(imageRef) if parts == nil { - return fmt.Errorf("invalid image reference") + return errors.New("invalid image reference") } imageID, err := s.getImageIDByRef(ctx, imageRef) if err != nil { @@ -505,6 +591,22 @@ func countBatchResultOutcomesInternal(imageRefs []string, results map[string]*im return successCount, errorCount } +// imageCheckResultMessageInternal derives an activity message level and text from +// a per-image update check result: errors become ERROR, available updates become +// SUCCESS, and up-to-date images stay INFO. +func imageCheckResultMessageInternal(imageRef string, res *imageupdate.Response) (models.ActivityMessageLevel, string) { + if res == nil { + return models.ActivityMessageLevelError, imageRef + ": check failed" + } + if err := strings.TrimSpace(res.Error); err != "" { + return models.ActivityMessageLevelError, fmt.Sprintf("%s: %s", imageRef, err) + } + if res.HasUpdate { + return models.ActivityMessageLevelSuccess, imageRef + " — update available" + } + return models.ActivityMessageLevelInfo, imageRef + " — up to date" +} + func extractRepoAndTagFromImage(dockerImage image.InspectResponse) (repo, tag string) { if len(dockerImage.RepoTags) > 0 && dockerImage.RepoTags[0] != ":" { if named, err := ref.ParseNormalizedNamed(dockerImage.RepoTags[0]); err == nil { @@ -702,6 +804,23 @@ func (s *ImageUpdateService) MarkImageRefUpToDateAfterPull(ctx context.Context, }) } +func (s *ImageUpdateService) getStoredUpdateByImageIDInternal(ctx context.Context, imageID string) (*models.ImageUpdateRecord, bool, error) { + imageID = strings.TrimSpace(imageID) + if s == nil || s.db == nil || imageID == "" { + return nil, false, nil + } + + var record models.ImageUpdateRecord + if err := s.db.WithContext(ctx).Where("id = ?", imageID).First(&record).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, fmt.Errorf("get stored image update by image id: %w", err) + } + + return &record, true, nil +} + // GetUnnotifiedUpdates returns a map of image IDs that have updates but haven't been notified yet func (s *ImageUpdateService) GetUnnotifiedUpdates(ctx context.Context) (map[string]*models.ImageUpdateRecord, error) { var records []models.ImageUpdateRecord @@ -892,16 +1011,25 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs return results, nil } + activityID := s.startImageUpdateActivityInternal(ctx, fmt.Sprintf("%d images", len(imageRefs)), len(imageRefs)) + ctx = s.activityService.Track(ctx, activityID) + s.appendImageUpdateActivityMessageInternal(ctx, activityID, models.ActivityMessageLevelInfo, fmt.Sprintf("Checking %d image references", len(imageRefs)), 5, "Preparing image update check") slog.DebugContext(ctx, "Starting batch image update check", "imageCount", len(imageRefs), "externalCredCount", len(externalCreds)) regRepos, initialResults, images := s.parseAndGroupImagesInternal(imageRefs) maps.Copy(results, initialResults) + for _, result := range initialResults { + if result != nil { + result.ActivityID = utils.StringPtrFromTrimmed(activityID) + } + } resolvedCreds := s.resolveBatchCredentialsInternal(ctx, externalCreds) slog.DebugContext(ctx, "Resolved batch registry credentials", "credentialCount", len(resolvedCreds), "registryCount", len(regRepos)) var mu sync.Mutex + completed := 0 g, groupCtx := errgroup.WithContext(ctx) g.SetLimit(10) // Limit concurrency @@ -919,13 +1047,21 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs defer s.registryLimiter.Release(registry) res, snapshot := s.checkSingleImageInBatchInternal(groupCtx, resolvedCreds, img.parts) + if res != nil { + res.ActivityID = utils.StringPtrFromTrimmed(activityID) + } mu.Lock() + completed++ + progress := 10 + int(float64(completed)/float64(len(images))*80) for _, ref := range img.refs { results[ref] = res } mu.Unlock() + level, message := imageCheckResultMessageInternal(img.canonicalRef, res) + s.appendImageUpdateActivityMessageInternal(groupCtx, activityID, level, message, progress, "Checking image") + if err := s.saveUpdateResultWithSnapshotInternal(groupCtx, img.canonicalRef, res, snapshot); err != nil { slog.WarnContext(groupCtx, "Failed to save update result", "imageRef", img.canonicalRef, "error", err.Error()) } @@ -935,68 +1071,73 @@ func (s *ImageUpdateService) CheckMultipleImages(ctx context.Context, imageRefs if err := g.Wait(); err != nil { slog.ErrorContext(ctx, "Batch check error", "error", err) + s.completeImageUpdateActivityInternal(ctx, activityID, false, "Image update check failed: "+err.Error()) return results, err } successCount, errorCount := countBatchResultOutcomesInternal(imageRefs, results) + finalSuccess := errorCount == 0 + finalMessage := fmt.Sprintf("Image update check completed: %d checked, %d errors", successCount, errorCount) + s.completeImageUpdateActivityInternal(ctx, activityID, finalSuccess, finalMessage) slog.InfoContext(ctx, "Batch image update check completed", "totalImages", len(imageRefs), "successCount", successCount, "errorCount", errorCount, "duration", time.Since(startBatch)) - if s.notificationService != nil { - // Use a context with timeout for notifications - notifCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() + s.sendBatchImageUpdateNotificationsInternal(ctx) - // Get only the updates that haven't been notified yet - unnotifiedUpdates, err := s.GetUnnotifiedUpdates(notifCtx) - switch { - case err != nil: - slog.WarnContext(ctx, "Failed to get unnotified updates", "error", err.Error()) - case len(unnotifiedUpdates) > 0: - // Convert unnotified records to the format expected by notification service - updatesToNotify := make(map[string]*imageupdate.Response) - imageIDsToMark := make([]string, 0, len(unnotifiedUpdates)) - - for imageID, record := range unnotifiedUpdates { - // Construct image ref from repository and tag - imageRef := fmt.Sprintf("%s:%s", record.Repository, record.Tag) - updatesToNotify[imageRef] = &imageupdate.Response{ - HasUpdate: record.HasUpdate, - UpdateType: record.UpdateType, - CurrentVersion: record.CurrentVersion, - LatestVersion: stringPtrToString(record.LatestVersion), - CurrentDigest: stringPtrToString(record.CurrentDigest), - LatestDigest: stringPtrToString(record.LatestDigest), - CheckTime: record.CheckTime, - ResponseTimeMs: record.ResponseTimeMs, - Error: stringPtrToString(record.LastError), - AuthMethod: stringPtrToString(record.AuthMethod), - AuthUsername: stringPtrToString(record.AuthUsername), - AuthRegistry: stringPtrToString(record.AuthRegistry), - UsedCredential: record.UsedCredential, - } - imageIDsToMark = append(imageIDsToMark, imageID) + return results, nil +} + +func (s *ImageUpdateService) sendBatchImageUpdateNotificationsInternal(ctx context.Context) { + if s.notificationService == nil { + return + } + + notifCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + unnotifiedUpdates, err := s.GetUnnotifiedUpdates(notifCtx) + switch { + case err != nil: + slog.WarnContext(ctx, "Failed to get unnotified updates", "error", err.Error()) + case len(unnotifiedUpdates) > 0: + updatesToNotify := make(map[string]*imageupdate.Response) + imageIDsToMark := make([]string, 0, len(unnotifiedUpdates)) + + for imageID, record := range unnotifiedUpdates { + imageRef := fmt.Sprintf("%s:%s", record.Repository, record.Tag) + updatesToNotify[imageRef] = &imageupdate.Response{ + HasUpdate: record.HasUpdate, + UpdateType: record.UpdateType, + CurrentVersion: record.CurrentVersion, + LatestVersion: stringPtrToString(record.LatestVersion), + CurrentDigest: stringPtrToString(record.CurrentDigest), + LatestDigest: stringPtrToString(record.LatestDigest), + CheckTime: record.CheckTime, + ResponseTimeMs: record.ResponseTimeMs, + Error: stringPtrToString(record.LastError), + AuthMethod: stringPtrToString(record.AuthMethod), + AuthUsername: stringPtrToString(record.AuthUsername), + AuthRegistry: stringPtrToString(record.AuthRegistry), + UsedCredential: record.UsedCredential, } + imageIDsToMark = append(imageIDsToMark, imageID) + } - slog.InfoContext(ctx, "Sending notifications for unnotified updates", "count", len(updatesToNotify)) + slog.InfoContext(ctx, "Sending notifications for unnotified updates", "count", len(updatesToNotify)) - if notifErr := s.notificationService.SendBatchImageUpdateNotification(notifCtx, updatesToNotify); notifErr != nil { - slog.WarnContext(ctx, "Failed to send batch update notification", "error", notifErr.Error()) - } else { - // Mark the images as notified only if notification was successful - if markErr := s.MarkUpdatesAsNotified(notifCtx, imageIDsToMark); markErr != nil { - slog.WarnContext(ctx, "Failed to mark updates as notified", "error", markErr.Error()) - } - } - default: - slog.DebugContext(ctx, "No new updates to notify") + if notifErr := s.notificationService.SendBatchImageUpdateNotification(notifCtx, updatesToNotify); notifErr != nil { + slog.WarnContext(ctx, "Failed to send batch update notification", "error", notifErr.Error()) + return } + if markErr := s.MarkUpdatesAsNotified(notifCtx, imageIDsToMark); markErr != nil { + slog.WarnContext(ctx, "Failed to mark updates as notified", "error", markErr.Error()) + } + default: + slog.DebugContext(ctx, "No new updates to notify") } - - return results, nil } func (s *ImageUpdateService) CheckAllImages(ctx context.Context, limit int, externalCreds []containerregistry.Credential) (map[string]*imageupdate.Response, error) { diff --git a/backend/internal/services/image_update_service_test.go b/backend/internal/services/image_update_service_test.go index 95f00ac809..2b243a6172 100644 --- a/backend/internal/services/image_update_service_test.go +++ b/backend/internal/services/image_update_service_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + ref "github.com/distribution/reference" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/types/imageupdate" @@ -20,11 +21,10 @@ import ( dockertypesimage "github.com/moby/moby/api/types/image" dockerregistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - ref "go.podman.io/image/v5/docker/reference" "gorm.io/gorm" ) @@ -523,7 +523,7 @@ func TestImageUpdateService_CheckImageUpdate_UsesRegistryFallback(t *testing.T) dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) result, err := svc.CheckImageUpdate(context.Background(), imageRef) require.NoError(t, err) @@ -563,7 +563,7 @@ func TestImageUpdateService_CheckMultipleImages_UsesRegistryFallback(t *testing. dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) results, err := svc.CheckMultipleImages(context.Background(), []string{imageRef}, nil) require.NoError(t, err) @@ -582,6 +582,53 @@ func TestImageUpdateService_CheckMultipleImages_UsesRegistryFallback(t *testing. assert.Equal(t, remoteDigest, stringPtrToString(saved.LatestDigest)) } +func TestImageUpdateService_CheckMultipleImagesCompletesActivityWhenRequestContextCanceledInternal(t *testing.T) { + db := setupImageUpdateTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Activity{}, &models.ActivityMessage{})) + + activityService := NewActivityService(db) + svc := NewImageUpdateService(db, nil, nil, nil, nil, nil, activityService) + + for range 5 { + require.NoError(t, svc.registryLimiter.Acquire(context.Background(), "docker.io")) + } + defer func() { + for range 5 { + svc.registryLimiter.Release("docker.io") + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := svc.CheckMultipleImages(ctx, []string{"nginx:latest"}, nil) + errCh <- err + }() + + var activity models.Activity + require.Eventually(t, func() bool { + return db.Where("type = ?", models.ActivityTypeImageUpdateCheck).First(&activity).Error == nil + }, time.Second, 10*time.Millisecond) + + cancel() + + select { + case err := <-errCh: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timed out waiting for image update check to return") + } + + require.Eventually(t, func() bool { + if err := db.First(&activity, "id = ?", activity.ID).Error; err != nil { + return false + } + return activity.Status == models.ActivityStatusFailed + }, time.Second, 10*time.Millisecond) + assert.Equal(t, "Image update check complete", activity.Step) + assert.Contains(t, activity.LatestMessage, "Image update check failed") +} + func TestImageUpdateService_CheckMultipleImages_UsesDockerHubCredentialsOnFirstAttempt(t *testing.T) { _, db := setupImageServiceAuthTest(t) require.NoError(t, db.AutoMigrate(&models.ImageUpdateRecord{}, &models.Event{})) @@ -618,7 +665,7 @@ func TestImageUpdateService_CheckMultipleImages_UsesDockerHubCredentialsOnFirstA dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) results, err := svc.CheckMultipleImages(context.Background(), []string{"docker.io/library/registry:3"}, nil) require.NoError(t, err) @@ -658,7 +705,7 @@ func TestImageUpdateService_CheckMultipleImages_PersistsRefScopedErrorsWhenLocal dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil) + svc := NewImageUpdateService(db, nil, registryService, dockerService, eventService, nil, nil) results, err := svc.CheckMultipleImages(context.Background(), []string{imageRef}, nil) require.NoError(t, err) @@ -686,7 +733,7 @@ func TestImageUpdateService_SaveUpdateResultWithSnapshotInternal_PersistsRegistr require.NoError(t, err) imageRef := serverURL.Host + "/library/nginx:alpine" - svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil) + svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil, nil) result := &imageupdate.Response{ HasUpdate: true, UpdateType: "digest", @@ -763,7 +810,7 @@ func TestImageUpdateService_MarkImageRefUpToDateAfterPull_ClearsMatchingRecordsA CheckTime: now, }).Error) - svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil) + svc := NewImageUpdateService(db, nil, nil, &DockerClientService{client: newTestDockerClient(t, server)}, nil, nil, nil) require.NoError(t, svc.MarkImageRefUpToDateAfterPull(context.Background(), imageRef)) diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 3e0e946ad8..6adcace018 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "sort" @@ -74,10 +75,10 @@ func (s *JobService) GetJobSchedules(ctx context.Context) jobschedule.Config { func (s *JobService) UpdateJobSchedules(ctx context.Context, updates jobschedule.Update) (jobschedule.Config, error) { if s == nil || s.db == nil || s.settings == nil { - return jobschedule.Config{}, fmt.Errorf("job service not initialized") + return jobschedule.Config{}, errors.New("job service not initialized") } if s.cfg != nil && s.cfg.UIConfigurationDisabled { - return jobschedule.Config{}, fmt.Errorf("job schedule updates are disabled") + return jobschedule.Config{}, errors.New("job schedule updates are disabled") } current := s.GetJobSchedules(ctx) @@ -200,7 +201,7 @@ func jobMetadataAffectedBySettingInternal(jobMeta meta.JobMetadata, changed map[ func (s *JobService) ListJobs(ctx context.Context) (*jobschedule.JobListResponse, error) { if s == nil || s.settings == nil { - return nil, fmt.Errorf("job service not initialized") + return nil, errors.New("job service not initialized") } allMetadata := meta.GetAllJobMetadata() @@ -243,7 +244,7 @@ func (s *JobService) RunJobNowInline(ctx context.Context, jobID string) error { func (s *JobService) getRunnableJobInternal(jobID string) (schedulertypes.Job, error) { if s == nil || s.scheduler == nil { - return nil, fmt.Errorf("job service or scheduler not initialized") + return nil, errors.New("job service or scheduler not initialized") } meta, ok := meta.GetJobMetadata(jobID) diff --git a/backend/internal/services/job_service_test.go b/backend/internal/services/job_service_test.go index 737155467f..b4facc479a 100644 --- a/backend/internal/services/job_service_test.go +++ b/backend/internal/services/job_service_test.go @@ -102,9 +102,8 @@ func TestJobService_UpdateJobSchedules_ReschedulesChangedJob(t *testing.T) { scheduler := newFakeJobSchedulerInternal("image-polling", "auto-update") jobSvc.SetScheduler(ctx, scheduler) - nextPollingInterval := "0 */10 * * * *" _, err = jobSvc.UpdateJobSchedules(ctx, jobschedule.Update{ - PollingInterval: &nextPollingInterval, + PollingInterval: new("0 */10 * * * *"), }) require.NoError(t, err) @@ -126,9 +125,8 @@ func TestJobService_UpdateJobSchedules_UsesLifecycleContextForReschedule(t *test scheduler := newFakeJobSchedulerInternal("image-polling") jobSvc.SetScheduler(lifecycleCtx, scheduler) - nextPollingInterval := "0 */10 * * * *" _, err = jobSvc.UpdateJobSchedules(requestCtx, jobschedule.Update{ - PollingInterval: &nextPollingInterval, + PollingInterval: new("0 */10 * * * *"), }) require.NoError(t, err) @@ -150,9 +148,8 @@ func TestJobService_UpdateJobSchedules_SkipsManagerOnlyJobsInAgentMode(t *testin scheduler := newFakeJobSchedulerInternal("environment-health") jobSvc.SetScheduler(ctx, scheduler) - nextHealthInterval := "0 */5 * * * *" _, err = jobSvc.UpdateJobSchedules(ctx, jobschedule.Update{ - EnvironmentHealthInterval: &nextHealthInterval, + EnvironmentHealthInterval: new("0 */5 * * * *"), }) require.NoError(t, err) diff --git a/backend/internal/services/lifecycle_service.go b/backend/internal/services/lifecycle_service.go new file mode 100644 index 0000000000..2dcaca710f --- /dev/null +++ b/backend/internal/services/lifecycle_service.go @@ -0,0 +1,755 @@ +package services + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + cerrdefs "github.com/containerd/errdefs" + "github.com/moby/moby/api/pkg/stdcopy" + containertypes "github.com/moby/moby/api/types/container" + mounttypes "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" + "github.com/getarcaneapp/arcane/backend/pkg/projects" +) + +// Lifecycle hook configuration limits and conventions. +const ( + // lifecycleWorkspaceMount is the in-container path the project dir is + // bind-mounted to. Scripts run with this as their working dir. + lifecycleWorkspaceMount = "/workspace" + // lifecycleMaxOutputBytes caps the stdout/stderr we retain on the + // GitOpsSync row. The full stream is still consumed; the tail is dropped. + lifecycleMaxOutputBytes = 16 * 1024 + // lifecycleDefaultTimeoutSec is used when a sync has no per-sync timeout + // configured. + lifecycleDefaultTimeoutSec = 60 + // lifecycleDefaultMaxTimeoutSec mirrors the settings default so callers + // always have a sane upper bound even if settings can't be loaded. + lifecycleDefaultMaxTimeoutSec = 300 + // lifecycleStreamDrainTimeout bounds how long we wait for the log copy + // goroutine to drain after the container exits, before force-closing. + // Mirrors the value used by VulnerabilityService for the same purpose. + lifecycleStreamDrainTimeout = 30 * time.Second +) + +// Last-run status values written to GitOpsSync.PreDeployLastRunStatus. +const ( + lifecycleStatusSuccess = "success" + lifecycleStatusFailed = "failed" + lifecycleStatusTimeout = "timeout" +) + +// lifecycleEnvKeyRegex enforces POSIX-style identifier syntax for env keys +// both in admin-configured Env and in scripts' KEY=VALUE stdout capture. +var lifecycleEnvKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// lifecycleExtraMount is one admin-configured bind mount for the lifecycle +// runner container. Stored on the GitOpsSync row as docker-CLI-style +// "src:tgt[:ro|:rw]" entries, one per line; never sourced from repo data. +type lifecycleExtraMount struct { + Source string + Target string + Readonly bool +} + +// LifecycleService runs pre-deploy lifecycle hooks declared on a project's +// GitOps sync. A hook is a script in the synced repo executed in a throwaway +// container immediately before the project is deployed, with optional capture +// of stdout as environment variables merged into the compose env. +// +// Trust model: the script is repo-trusted code, equivalent to compose.yaml in +// the same repo. Anyone who can push to that repo can change what the script +// does on the next deploy. The trust event is configuring a script path on +// the GitOps sync, not each individual deploy. +type LifecycleService struct { + db *database.DB + settingsService *SettingsService + eventService *EventService + dockerService *DockerClientService +} + +// NewLifecycleService constructs a LifecycleService wired against shared +// infrastructure. The Docker client is obtained lazily on each hook run via +// dockerService.GetClient so reconnects are transparent. +func NewLifecycleService(db *database.DB, settingsService *SettingsService, eventService *EventService, dockerService *DockerClientService) *LifecycleService { + return &LifecycleService{ + db: db, + settingsService: settingsService, + eventService: eventService, + dockerService: dockerService, + } +} + +// RunPreDeploy executes the pre-deploy lifecycle hook for a project, if one +// is configured on its GitOps sync. +// +// Callers should invoke this unconditionally before deploying — when no hook +// is configured, when lifecycle hooks are disabled globally, or when the +// project is not GitOps-managed, this is a no-op and returns nil. +// +// A non-zero exit code, a script timeout, or any infrastructure failure +// returns an error that aborts the deploy. The last-run state on the +// GitOpsSync row is updated on every invocation that reaches the run step, +// regardless of outcome. +func (s *LifecycleService) RunPreDeploy(ctx context.Context, project *models.Project) error { + if project == nil || project.GitOpsManagedBy == nil || *project.GitOpsManagedBy == "" { + return nil + } + if !s.settingsService.GetBoolSetting(ctx, "lifecycleEnabled", false) { + return nil + } + + sync, err := s.loadGitOpsSyncForProjectInternal(ctx, project.ID) + if err != nil { + return fmt.Errorf("failed to load gitops sync for lifecycle hook: %w", err) + } + if sync == nil || sync.PreDeployScriptPath == nil || strings.TrimSpace(*sync.PreDeployScriptPath) == "" { + return nil + } + + return s.executePreDeployInternal(ctx, project, sync) +} + +func (s *LifecycleService) executePreDeployInternal(ctx context.Context, project *models.Project, sync *models.GitOpsSync) error { + runnerImage := strings.TrimSpace(stringValueInternal(sync.PreDeployRunnerImage)) + if runnerImage == "" { + return fmt.Errorf("pre-deploy script %q is configured but no runner image is set on the GitOps sync", *sync.PreDeployScriptPath) + } + + scriptPath := strings.TrimSpace(*sync.PreDeployScriptPath) + if err := validateScriptPathInternal(project.Path, scriptPath); err != nil { + return fmt.Errorf("invalid pre-deploy script path: %w", err) + } + + hookEnv, err := parseLifecycleEnvTextInternal(sync.PreDeployEnv) + if err != nil { + return fmt.Errorf("invalid lifecycle env config: %w", err) + } + extraMounts, err := parseLifecycleExtraMountsTextInternal(sync.PreDeployExtraMounts) + if err != nil { + return fmt.Errorf("invalid lifecycle extra mounts config: %w", err) + } + + timeout := s.resolveTimeoutInternal(ctx, sync.PreDeployTimeoutSec) + + slog.InfoContext(ctx, "running pre-deploy lifecycle hook", + "projectID", project.ID, + "syncID", sync.ID, + "scriptPath", scriptPath, + "runnerImage", runnerImage, + "timeoutSec", int(timeout/time.Second), + ) + + start := time.Now() + stdoutContent, stderrContent, exitCode, runErr := s.runScriptInContainerInternal( + ctx, + runnerImage, + project.Path, + scriptPath, + hookEnv, + extraMounts, + sync.PreDeployNetworkMode, + timeout, + ) + durationMs := time.Since(start).Milliseconds() + + status := lifecycleStatusForResultInternal(exitCode, runErr) + // stdoutBuf/stderrBuf already enforce lifecycleMaxOutputBytes each with a + // proper "..." marker, so the combined string is already + // bounded and we don't slice again here — a second byte-boundary cut + // could land mid-UTF-8 codepoint and produce garbled output. + persistedOutput := combineLifecycleOutputInternal(stdoutContent, stderrContent) + s.persistLastRunInternal(ctx, sync.ID, status, persistedOutput, start) + s.emitLifecycleEventInternal(ctx, project, sync, status, exitCode, durationMs, runErr) + + if runErr != nil { + return runErr + } + if exitCode != 0 { + return fmt.Errorf("pre-deploy script exited with status %d", exitCode) + } + return nil +} + +// runScriptInContainerInternal performs the docker run + log capture + wait. +// On the happy path it returns stdout, stderr, the script's exit code and a +// nil error. context.DeadlineExceeded from the per-run timeout is wrapped and +// returned as the error so callers can distinguish a timeout from a non-zero +// exit. +func (s *LifecycleService) runScriptInContainerInternal( + ctx context.Context, + runnerImage string, + projectPath string, + scriptPath string, + hookEnv map[string]string, + extraMounts []lifecycleExtraMount, + networkMode string, + timeout time.Duration, +) (stdoutContent string, stderrContent string, exitCode int64, err error) { + dockerClient, dErr := s.dockerService.GetClient(ctx) + if dErr != nil { + return "", "", 0, fmt.Errorf("failed to connect to Docker: %w", dErr) + } + + if err := s.ensureRunnerImageInternal(ctx, dockerClient, runnerImage); err != nil { + return "", "", 0, fmt.Errorf("failed to ensure runner image %s: %w", runnerImage, err) + } + + // Resolve the workspace mount. When Arcane runs inside a container whose + // /app/data is backed by a named volume, a plain bind-mount of the + // translated host path (e.g. /var/lib/docker/volumes/.../_data/projects/X) + // is unreliable — Docker Desktop on WSL2 refuses it outright, and any + // daemon with non-trivial volume storage may behave differently. Using + // the named volume directly with VolumeOptions.Subpath sidesteps the + // translation entirely. For host-bind /app/data the helper returns a + // plain bind that points at the right host subdir. If Arcane is running + // on the host (no container inspect available), fall back to a bind on + // the project path as-is. + workspaceMount, mountErr := dockerutils.MountForCurrentContainerSubpath(ctx, dockerClient, projectPath, lifecycleWorkspaceMount) + if mountErr != nil { + slog.WarnContext(ctx, "failed to derive workspace mount; falling back to bind on project path", "projectPath", projectPath, "error", mountErr) + } + if workspaceMount == nil { + workspaceMount = &mounttypes.Mount{Type: mounttypes.TypeBind, Source: projectPath, Target: lifecycleWorkspaceMount} + } + + // Cmd is the script path alone — no interpreter wrapper. The script's + // shebang selects the interpreter (which must exist in the runner image), + // and the file must be executable on the host (git preserves +x through + // clone, so a committed +x script works transparently). This keeps + // Arcane out of the language-choice business and matches standard + // docker run semantics. + // + // Entrypoint is explicitly cleared because many purpose-built images set + // ENTRYPOINT to their primary tool (e.g. the official getsops/sops image + // has ENTRYPOINT=["sops"]). Without this override, our Cmd would become + // an argument to that tool rather than replacing it. + config := &containertypes.Config{ + Image: runnerImage, + Entrypoint: []string{}, + Cmd: []string{filepath.ToSlash(filepath.Join(lifecycleWorkspaceMount, scriptPath))}, + WorkingDir: lifecycleWorkspaceMount, + Env: envMapToSliceInternal(hookEnv), + AttachStdout: true, + AttachStderr: true, + Tty: false, + Labels: map[string]string{ + libarcane.InternalResourceLabel: "true", + }, + } + // no-new-privileges blocks the script (or anything it spawns) from + // gaining capabilities via setuid binaries inside the runner image. + // CapDrop ALL removes the default capability set Docker grants to root + // in a container (NET_RAW, NET_BIND_SERVICE, SETUID, etc.) — a hook + // script decrypting secrets or generating config has no need for any + // of them, and dropping them takes the most-common privilege-escalation + // primitives off the table even if a malicious script gets in. + hostConfig := &containertypes.HostConfig{ + Mounts: buildLifecycleMountsInternal(workspaceMount, extraMounts), + NetworkMode: resolveLifecycleNetworkModeInternal(networkMode), + SecurityOpt: []string{"no-new-privileges:true"}, + CapDrop: []string{"ALL"}, + AutoRemove: false, + } + + apiTimeoutSec := s.settingsService.GetIntSetting(ctx, "dockerApiTimeout", 0) + createCtx, createCancel := timeouts.WithTimeout(ctx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer createCancel() + resp, err := dockerClient.ContainerCreate(createCtx, client.ContainerCreateOptions{ + Config: config, + HostConfig: hostConfig, + }) + if err != nil { + return "", "", 0, fmt.Errorf("create lifecycle container: %w", err) + } + containerID := resp.ID + defer removeLifecycleContainerInternal(ctx, dockerClient, containerID, apiTimeoutSec) + + startCtx, startCancel := timeouts.WithTimeout(ctx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer startCancel() + if _, err := dockerClient.ContainerStart(startCtx, containerID, client.ContainerStartOptions{}); err != nil { + return "", "", 0, fmt.Errorf("start lifecycle container: %w", err) + } + + logsCtx, logsCancel := context.WithCancel(ctx) + defer logsCancel() + logs, err := dockerClient.ContainerLogs(logsCtx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + return "", "", 0, fmt.Errorf("stream lifecycle container logs: %w", err) + } + + stdoutBuf := newLifecycleCappedBufferInternal(lifecycleMaxOutputBytes) + stderrBuf := newLifecycleCappedBufferInternal(lifecycleMaxOutputBytes) + logDone := make(chan error, 1) + go func() { + _, copyErr := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) + logDone <- copyErr + }() + + waitCtx, waitCancel := context.WithTimeout(ctx, timeout) + defer waitCancel() + waitResp := dockerClient.ContainerWait(waitCtx, containerID, client.ContainerWaitOptions{ + Condition: containertypes.WaitConditionNotRunning, + }) + + select { + case result := <-waitResp.Result: + exitCode = result.StatusCode + if result.Error != nil && result.Error.Message != "" { + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + return stdoutBuf.String(), stderrBuf.String(), exitCode, fmt.Errorf("lifecycle container reported error: %s", result.Error.Message) + } + case waitErr := <-waitResp.Error: + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + if errors.Is(waitErr, context.DeadlineExceeded) || errors.Is(waitCtx.Err(), context.DeadlineExceeded) { + return stdoutBuf.String(), stderrBuf.String(), 0, fmt.Errorf("pre-deploy script timed out after %s: %w", timeout, context.DeadlineExceeded) + } + if errors.Is(waitErr, context.Canceled) { + return stdoutBuf.String(), stderrBuf.String(), 0, fmt.Errorf("pre-deploy script cancelled: %w", waitErr) + } + return stdoutBuf.String(), stderrBuf.String(), 0, fmt.Errorf("lifecycle container wait failed: %w", waitErr) + } + + drainLifecycleLogsInternal(ctx, logsCancel, logs, logDone) + + return stdoutBuf.String(), stderrBuf.String(), exitCode, nil +} + +// ensureRunnerImageInternal makes sure the runner image is available locally, +// pulling it on a miss. Mirrors the pattern used by VulnerabilityService for +// the Trivy scanner image, including reuse of the dockerImagePullTimeout +// setting so operators on slow networks can tune it once. +func (s *LifecycleService) ensureRunnerImageInternal(ctx context.Context, dockerClient *client.Client, image string) error { + if _, err := dockerClient.ImageInspect(ctx, image); err == nil { + return nil + } + + pullTimeoutSec := s.settingsService.GetIntSetting(ctx, "dockerImagePullTimeout", 0) + pullCtx, pullCancel := timeouts.WithTimeout(ctx, pullTimeoutSec, timeouts.DefaultDockerImagePull) + defer pullCancel() + + pullReader, err := dockerClient.ImagePull(pullCtx, image, client.ImagePullOptions{}) + if err != nil { + if errors.Is(pullCtx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("runner image pull timed out for %s (increase dockerImagePullTimeout setting if needed)", image) + } + return fmt.Errorf("pull runner image %s: %w", image, err) + } + defer func() { _ = pullReader.Close() }() + + if err := dockerutils.ConsumeJSONMessageStream(pullReader, nil); err != nil { + return fmt.Errorf("failed to complete runner image pull: %w", err) + } + return nil +} + +func (s *LifecycleService) resolveTimeoutInternal(ctx context.Context, perSyncTimeoutSec int) time.Duration { + timeoutSec := perSyncTimeoutSec + if timeoutSec <= 0 { + timeoutSec = lifecycleDefaultTimeoutSec + } + maxTimeoutSec := s.settingsService.GetIntSetting(ctx, "lifecycleMaxTimeoutSec", lifecycleDefaultMaxTimeoutSec) + if maxTimeoutSec > 0 && timeoutSec > maxTimeoutSec { + timeoutSec = maxTimeoutSec + } + return time.Duration(timeoutSec) * time.Second +} + +func (s *LifecycleService) persistLastRunInternal(ctx context.Context, syncID, status, output string, runAt time.Time) { + persistCtx := context.WithoutCancel(ctx) + err := s.db.WithContext(persistCtx). + Model(&models.GitOpsSync{}). + Where("id = ?", syncID). + Updates(map[string]any{ + "pre_deploy_last_run_at": runAt, + "pre_deploy_last_run_status": status, + "pre_deploy_last_run_output": output, + }).Error + if err != nil { + slog.WarnContext(ctx, "failed to persist lifecycle last-run state", "syncID", syncID, "error", err) + } +} + +func (s *LifecycleService) emitLifecycleEventInternal( + ctx context.Context, + project *models.Project, + sync *models.GitOpsSync, + status string, + exitCode int64, + durationMs int64, + runErr error, +) { + severity := models.EventSeveritySuccess + title := "Pre-deploy lifecycle hook succeeded: " + project.Name + description := fmt.Sprintf("Script %s exited with code %d in %dms", stringValueInternal(sync.PreDeployScriptPath), exitCode, durationMs) + if status != lifecycleStatusSuccess { + severity = models.EventSeverityWarning + title = fmt.Sprintf("Pre-deploy lifecycle hook %s: %s", status, project.Name) + if runErr != nil { + description = runErr.Error() + } + } + + metadata := models.JSON{ + "scriptPath": stringValueInternal(sync.PreDeployScriptPath), + "runnerImage": stringValueInternal(sync.PreDeployRunnerImage), + "exitCode": exitCode, + "durationMs": durationMs, + "gitopsSyncId": sync.ID, + "status": status, + } + + _, err := s.eventService.CreateEvent(ctx, CreateEventRequest{ + Type: models.EventTypeLifecycleExecute, + Severity: severity, + Title: title, + Description: description, + ResourceType: new("project"), + ResourceID: new(project.ID), + ResourceName: new(project.Name), + EnvironmentID: new(sync.EnvironmentID), + Metadata: metadata, + }) + if err != nil { + slog.WarnContext(ctx, "failed to emit lifecycle.execute event", "syncID", sync.ID, "error", err) + } +} + +// loadGitOpsSyncForProjectInternal returns the GitOps sync linked to the +// given project, or nil if the project is not GitOps-managed. A nil sync +// with nil error is a normal outcome, not a failure. +func (s *LifecycleService) loadGitOpsSyncForProjectInternal(ctx context.Context, projectID string) (*models.GitOpsSync, error) { + if projectID == "" { + return nil, nil + } + + var sync models.GitOpsSync + err := s.db.WithContext(ctx). + Where("project_id = ?", projectID). + First(&sync).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &sync, nil +} + +// validateScriptPathInternal rejects paths that escape the project directory +// or refer to symlinks/binaries. Reuses the same safety helper that gates +// the existing project include-file editor. +func validateScriptPathInternal(projectPath, scriptPath string) error { + if scriptPath == "" { + return errors.New("script path is empty") + } + // scriptPath is a POSIX repo path, not a host path; use path.IsAbs so the + // check behaves the same on Windows-based contributor machines. + if path.IsAbs(filepath.ToSlash(scriptPath)) { + return fmt.Errorf("script path %q must be relative to the project directory", scriptPath) + } + + absProject, err := filepath.Abs(projectPath) + if err != nil { + return fmt.Errorf("resolve project path: %w", err) + } + absScript, err := filepath.Abs(filepath.Join(absProject, scriptPath)) + if err != nil { + return fmt.Errorf("resolve script path: %w", err) + } + if !projects.IsSafeSubdirectory(absProject, absScript) { + return fmt.Errorf("script path %q escapes project directory", scriptPath) + } + + info, err := os.Lstat(absScript) + if err != nil { + return fmt.Errorf("stat script %q: %w", scriptPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("script path %q is a symlink; symlinks are not allowed", scriptPath) + } + if info.IsDir() { + return fmt.Errorf("script path %q refers to a directory", scriptPath) + } + return nil +} + +// parseLifecycleEnvTextInternal reads admin-configured env config as the +// same KEY=VALUE text format used by .env files: one entry per line, blank +// and "#"-prefixed lines ignored, keys must match POSIX identifier syntax. +// Reuses the strict parser also used for stdout-capture. +func parseLifecycleEnvTextInternal(raw *string) (map[string]string, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return map[string]string{}, nil + } + env, err := parseKeyValueEnvInternal(*raw) + if err != nil { + return nil, fmt.Errorf("invalid env entry: %w", err) + } + return env, nil +} + +// parseLifecycleExtraMountsTextInternal reads admin-configured bind mounts +// in docker-CLI "src:tgt[:ro|:rw]" form, one per line. Blank and +// "#"-prefixed lines are ignored. Both source and target must be absolute +// paths; mode defaults to read-write. +func parseLifecycleExtraMountsTextInternal(raw *string) ([]lifecycleExtraMount, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + + var mounts []lifecycleExtraMount + scanner := bufio.NewScanner(strings.NewReader(*raw)) + scanner.Buffer(make([]byte, 0, 4*1024), 64*1024) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, ":") + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("line %d: expected src:tgt[:ro|:rw], got %q", lineNum, line) + } + + mount := lifecycleExtraMount{Source: parts[0], Target: parts[1]} + if len(parts) == 3 { + switch parts[2] { + case "ro": + mount.Readonly = true + case "rw": + mount.Readonly = false + default: + return nil, fmt.Errorf("line %d: invalid mode %q (expected \"ro\" or \"rw\")", lineNum, parts[2]) + } + } + + // Mount sources/targets are interpreted by the Docker daemon as POSIX + // host/container paths, so use path.IsAbs to avoid host-OS quirks + // (filepath.IsAbs("/x") returns false on Windows). + if !path.IsAbs(filepath.ToSlash(mount.Source)) { + return nil, fmt.Errorf("line %d: source %q must be an absolute path", lineNum, mount.Source) + } + if !path.IsAbs(filepath.ToSlash(mount.Target)) { + return nil, fmt.Errorf("line %d: target %q must be an absolute path", lineNum, mount.Target) + } + + mounts = append(mounts, mount) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read extra mounts config: %w", err) + } + return mounts, nil +} + +// parseKeyValueEnvInternal parses stdout as a strict newline-separated list of +// KEY=VALUE pairs and returns them as a map. Blank lines and lines starting +// with '#' (after trimming leading whitespace) are ignored. Anything else is +// rejected — we'd rather fail a deploy than silently merge garbage into the +// compose env. +func parseKeyValueEnvInternal(stdout string) (map[string]string, error) { + env := map[string]string{} + scanner := bufio.NewScanner(strings.NewReader(stdout)) + scanner.Buffer(make([]byte, 0, 64*1024), lifecycleMaxOutputBytes) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimRight(scanner.Text(), "\r") + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx <= 0 { + return nil, fmt.Errorf("line %d: expected KEY=VALUE, got %q", lineNum, line) + } + key := line[:idx] + value := line[idx+1:] + if !lifecycleEnvKeyRegex.MatchString(key) { + return nil, fmt.Errorf("line %d: invalid env key %q", lineNum, key) + } + env[key] = value + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read stdout: %w", err) + } + return env, nil +} + +func envMapToSliceInternal(env map[string]string) []string { + if len(env) == 0 { + return nil + } + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} + +func buildLifecycleMountsInternal(workspace *mounttypes.Mount, extras []lifecycleExtraMount) []mounttypes.Mount { + mounts := make([]mounttypes.Mount, 0, 1+len(extras)) + // The project directory is mounted read-write so scripts can write + // artifacts the deploy then consumes — e.g. `sops -d secrets.enc.env > .env`. + // Anything written persists on the host until the next sync overwrites it. + // The workspace mount itself is built by MountForCurrentContainerSubpath + // so it carries the right Type (bind or volume) and any required + // VolumeOptions.Subpath. + mounts = append(mounts, *workspace) + for _, m := range extras { + mounts = append(mounts, mounttypes.Mount{ + Type: mounttypes.TypeBind, + Source: m.Source, + Target: m.Target, + ReadOnly: m.Readonly, + }) + } + return mounts +} + +// resolveLifecycleNetworkModeInternal maps the stored PreDeployNetworkMode +// string to the containertypes.NetworkMode value used by the Docker SDK. +// An empty stored value is treated as "none" so scripts never accidentally +// run on the default bridge network. +func resolveLifecycleNetworkModeInternal(mode string) containertypes.NetworkMode { + trimmed := strings.TrimSpace(mode) + if trimmed == "" { + trimmed = "none" + } + return containertypes.NetworkMode(trimmed) +} + +func lifecycleStatusForResultInternal(exitCode int64, runErr error) string { + if runErr != nil { + if errors.Is(runErr, context.DeadlineExceeded) { + return lifecycleStatusTimeout + } + return lifecycleStatusFailed + } + if exitCode != 0 { + return lifecycleStatusFailed + } + return lifecycleStatusSuccess +} + +func combineLifecycleOutputInternal(stdout, stderr string) string { + stdout = strings.TrimRight(stdout, "\n") + stderr = strings.TrimRight(stderr, "\n") + switch { + case stdout == "" && stderr == "": + return "" + case stderr == "": + return stdout + case stdout == "": + return "--- stderr ---\n" + stderr + default: + return stdout + "\n--- stderr ---\n" + stderr + } +} + +func removeLifecycleContainerInternal(ctx context.Context, dockerClient *client.Client, containerID string, apiTimeoutSec int) { + if dockerClient == nil || containerID == "" { + return + } + cleanupCtx := context.WithoutCancel(ctx) + cleanupCtx, cleanupCancel := timeouts.WithTimeout(cleanupCtx, apiTimeoutSec, timeouts.DefaultDockerAPI) + defer cleanupCancel() + if _, err := dockerClient.ContainerRemove(cleanupCtx, containerID, client.ContainerRemoveOptions{Force: true}); err != nil && !cerrdefs.IsNotFound(err) { + slog.WarnContext(cleanupCtx, "failed to remove lifecycle container", "containerID", containerID, "error", err) + } +} + +// drainLifecycleLogsInternal waits for the log copy goroutine to finish with +// a bounded deadline, then force-closes the stream. Mirrors the trivy +// pattern, which exists to tolerate Docker variants that don't EOF cleanly +// when a container exits. +func drainLifecycleLogsInternal(ctx context.Context, logsCancel context.CancelFunc, logs io.ReadCloser, logDone <-chan error) { + timer := time.NewTimer(lifecycleStreamDrainTimeout) + defer timer.Stop() + select { + case <-logDone: + case <-timer.C: + slog.DebugContext(ctx, "lifecycle log stream did not close after container exit; force-closing") + } + logsCancel() + _ = logs.Close() +} + +func stringValueInternal(p *string) string { + if p == nil { + return "" + } + return *p +} + +// lifecycleCappedBuffer is an io.Writer that buffers up to maxBytes and +// silently drops the rest, recording the fact that it truncated. The String +// method returns the captured bytes with a truncation marker appended when +// applicable. Safe for concurrent use from a single stdcopy goroutine. +type lifecycleCappedBuffer struct { + mu sync.Mutex + buf bytes.Buffer + maxBytes int + truncated bool +} + +func newLifecycleCappedBufferInternal(maxBytes int) *lifecycleCappedBuffer { + return &lifecycleCappedBuffer{maxBytes: maxBytes} +} + +func (b *lifecycleCappedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.maxBytes <= 0 { + return len(p), nil + } + remaining := b.maxBytes - b.buf.Len() + if remaining <= 0 { + b.truncated = true + return len(p), nil + } + if len(p) <= remaining { + _, _ = b.buf.Write(p) + } else { + _, _ = b.buf.Write(p[:remaining]) + b.truncated = true + } + return len(p), nil +} + +func (b *lifecycleCappedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + s := b.buf.String() + if b.truncated { + s += "\n..." + } + return s +} diff --git a/backend/internal/services/lifecycle_service_test.go b/backend/internal/services/lifecycle_service_test.go new file mode 100644 index 0000000000..1eb6f1795e --- /dev/null +++ b/backend/internal/services/lifecycle_service_test.go @@ -0,0 +1,407 @@ +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + glsqlite "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" +) + +func setupLifecycleTestDB(t *testing.T) *database.DB { + t.Helper() + db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.SettingVariable{}, + &models.Project{}, + &models.GitOpsSync{}, + &models.Event{}, + )) + return &database.DB{DB: db} +} + +func newLifecycleTestService(t *testing.T, db *database.DB) (*LifecycleService, *SettingsService) { + t.Helper() + settings, err := NewSettingsService(context.Background(), db) + require.NoError(t, err) + events := NewEventService(db, nil, nil) + return NewLifecycleService(db, settings, events, nil), settings +} + +func writeLifecycleProjectDirWithScript(t *testing.T, scriptRel, scriptBody string) string { + t.Helper() + dir := t.TempDir() + if scriptRel != "" { + full := filepath.Join(dir, scriptRel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(scriptBody), 0o644)) + } + return dir +} + +func TestParseLifecycleEnvText_Empty(t *testing.T) { + got, err := parseLifecycleEnvTextInternal(nil) + require.NoError(t, err) + assert.Empty(t, got) + + empty := " " + got, err = parseLifecycleEnvTextInternal(&empty) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestParseLifecycleEnvText_Valid(t *testing.T) { + raw := "FOO=bar\nBAZ_2=qux" + got, err := parseLifecycleEnvTextInternal(&raw) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FOO": "bar", "BAZ_2": "qux"}, got) +} + +func TestParseLifecycleEnvText_RejectsInvalidKeys(t *testing.T) { + cases := map[string]string{ + "leading digit": "1FOO=bar", + "dash": "FOO-BAR=baz", + "dot": "foo.bar=baz", + "no equals": "FOO bar", + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + _, err := parseLifecycleEnvTextInternal(&raw) + require.Error(t, err) + }) + } +} + +func TestParseLifecycleExtraMountsText_Empty(t *testing.T) { + got, err := parseLifecycleExtraMountsTextInternal(nil) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestParseLifecycleExtraMountsText_Valid(t *testing.T) { + raw := "/host/age.key:/age.key:ro\n/host/data:/data" + got, err := parseLifecycleExtraMountsTextInternal(&raw) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "/host/age.key", got[0].Source) + assert.Equal(t, "/age.key", got[0].Target) + assert.True(t, got[0].Readonly) + assert.Equal(t, "/host/data", got[1].Source) + assert.Equal(t, "/data", got[1].Target) + assert.False(t, got[1].Readonly) +} + +func TestParseLifecycleExtraMountsText_RejectsRelativePaths(t *testing.T) { + cases := []string{ + "relative/path:/in/container", + "/host/path:relative/target", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + _, err := parseLifecycleExtraMountsTextInternal(&raw) + require.Error(t, err) + }) + } +} + +func TestParseLifecycleExtraMountsText_RejectsInvalidMode(t *testing.T) { + raw := "/host/data:/data:wat" + _, err := parseLifecycleExtraMountsTextInternal(&raw) + require.Error(t, err) +} + +func TestParseKeyValueEnv_HappyPath(t *testing.T) { + stdout := "FOO=bar\n# comment line\n\nBAZ_2=hello world\n" + got, err := parseKeyValueEnvInternal(stdout) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FOO": "bar", "BAZ_2": "hello world"}, got) +} + +func TestParseKeyValueEnv_RejectsMalformedLines(t *testing.T) { + cases := map[string]string{ + "no equals": "FOO bar", + "empty key": "=value", + "bad key": "foo-bar=baz", + "digit start": "1FOO=bar", + } + for name, stdout := range cases { + t.Run(name, func(t *testing.T) { + _, err := parseKeyValueEnvInternal(stdout) + require.Error(t, err) + }) + } +} + +func TestParseKeyValueEnv_AllowsEqualsInValue(t *testing.T) { + stdout := "TOKEN=abc=def=ghi" + got, err := parseKeyValueEnvInternal(stdout) + require.NoError(t, err) + assert.Equal(t, "abc=def=ghi", got["TOKEN"]) +} + +func TestValidateScriptPath_HappyPath(t *testing.T) { + dir := writeLifecycleProjectDirWithScript(t, "scripts/pre-deploy.sh", "#!/bin/sh\necho hi\n") + err := validateScriptPathInternal(dir, "scripts/pre-deploy.sh") + require.NoError(t, err) +} + +func TestValidateScriptPath_RejectsAbsolute(t *testing.T) { + dir := t.TempDir() + err := validateScriptPathInternal(dir, "/etc/passwd") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsTraversal(t *testing.T) { + dir := t.TempDir() + err := validateScriptPathInternal(dir, "../escape.sh") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsMissingFile(t *testing.T) { + dir := t.TempDir() + err := validateScriptPathInternal(dir, "missing.sh") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "scripts"), 0o755)) + err := validateScriptPathInternal(dir, "scripts") + require.Error(t, err) +} + +func TestValidateScriptPath_RejectsSymlink(t *testing.T) { + if _, err := os.Stat("/tmp"); err != nil { + t.Skip("symlink test requires unix-like FS") + } + dir := t.TempDir() + target := filepath.Join(dir, "real.sh") + require.NoError(t, os.WriteFile(target, []byte("echo"), 0o644)) + link := filepath.Join(dir, "link.sh") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink in test env: %v", err) + } + err := validateScriptPathInternal(dir, "link.sh") + require.Error(t, err) +} + +func TestLifecycleStatusForResult(t *testing.T) { + assert.Equal(t, lifecycleStatusSuccess, lifecycleStatusForResultInternal(0, nil)) + assert.Equal(t, lifecycleStatusFailed, lifecycleStatusForResultInternal(1, nil)) + assert.Equal(t, lifecycleStatusFailed, lifecycleStatusForResultInternal(0, errors.New("boom"))) + assert.Equal(t, lifecycleStatusTimeout, lifecycleStatusForResultInternal(0, context.DeadlineExceeded)) +} + +func TestCombineLifecycleOutput(t *testing.T) { + cases := []struct { + stdout, stderr, want string + }{ + {"", "", ""}, + {"hello", "", "hello"}, + {"", "boom", "--- stderr ---\nboom"}, + {"hello\n", "boom\n", "hello\n--- stderr ---\nboom"}, + } + for _, c := range cases { + got := combineLifecycleOutputInternal(c.stdout, c.stderr) + assert.Equal(t, c.want, got, "stdout=%q stderr=%q", c.stdout, c.stderr) + } +} + +func TestRunPreDeploy_NilProject(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + require.NoError(t, svc.RunPreDeploy(context.Background(), nil)) +} + +func TestRunPreDeploy_ProjectNotGitOpsManaged(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: nil, + } + require.NoError(t, svc.RunPreDeploy(context.Background(), project)) +} + +func TestRunPreDeploy_KillSwitchDisabled(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + + syncID := "sync-1" + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + scriptPath := "pre-deploy.sh" + runnerImage := "alpine:latest" + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + PreDeployScriptPath: &scriptPath, + PreDeployRunnerImage: &runnerImage, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + require.NoError(t, svc.RunPreDeploy(context.Background(), project)) +} + +func TestRunPreDeploy_NoScriptConfigured(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + syncID := "sync-1" + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + require.NoError(t, svc.RunPreDeploy(context.Background(), project)) +} + +func TestRunPreDeploy_MissingRunnerImage(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + syncID := "sync-1" + projectDir := writeLifecycleProjectDirWithScript(t, "pre-deploy.sh", "echo hi\n") + project := &models.Project{ + Name: "demo", + Path: projectDir, + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + scriptPath := "pre-deploy.sh" + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + PreDeployScriptPath: &scriptPath, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + err := svc.RunPreDeploy(context.Background(), project) + require.Error(t, err) + assert.Contains(t, err.Error(), "runner image") +} + +func TestRunPreDeploy_PathTraversalRejected(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, settings := newLifecycleTestService(t, db) + require.NoError(t, settings.SetStringSetting(context.Background(), "lifecycleEnabled", "true")) + + syncID := "sync-1" + project := &models.Project{ + Name: "demo", + Path: t.TempDir(), + GitOpsManagedBy: &syncID, + } + project.ID = "proj-1" + require.NoError(t, db.Create(project).Error) + + scriptPath := "../etc/passwd" + runnerImage := "alpine:latest" + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + ProjectID: &project.ID, + PreDeployScriptPath: &scriptPath, + PreDeployRunnerImage: &runnerImage, + } + sync.ID = syncID + require.NoError(t, db.Create(sync).Error) + + err := svc.RunPreDeploy(context.Background(), project) + require.Error(t, err) + assert.Contains(t, err.Error(), "script path") +} + +func TestLoadGitOpsSyncForProject_ReturnsNilWhenAbsent(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + sync, err := svc.loadGitOpsSyncForProjectInternal(context.Background(), "nonexistent") + require.NoError(t, err) + assert.Nil(t, sync) +} + +func TestPersistLastRun_UpdatesGitOpsSyncRow(t *testing.T) { + db := setupLifecycleTestDB(t) + svc, _ := newLifecycleTestService(t, db) + + sync := &models.GitOpsSync{ + Name: "demo-sync", + EnvironmentID: "0", + RepositoryID: "repo-1", + Branch: "main", + ComposePath: "docker-compose.yaml", + TargetType: "project", + ProjectName: "demo", + } + sync.ID = "sync-1" + require.NoError(t, db.Create(sync).Error) + + now := time.Now().UTC().Truncate(time.Second) + svc.persistLastRunInternal(context.Background(), sync.ID, lifecycleStatusSuccess, "all good", now) + + var got models.GitOpsSync + require.NoError(t, db.Where("id = ?", sync.ID).First(&got).Error) + require.NotNil(t, got.PreDeployLastRunAt) + require.NotNil(t, got.PreDeployLastRunStatus) + require.NotNil(t, got.PreDeployLastRunOutput) + assert.Equal(t, lifecycleStatusSuccess, *got.PreDeployLastRunStatus) + assert.Equal(t, "all good", *got.PreDeployLastRunOutput) +} diff --git a/backend/internal/services/log_stream_util.go b/backend/internal/services/log_stream_util.go deleted file mode 100644 index 64ada4488c..0000000000 --- a/backend/internal/services/log_stream_util.go +++ /dev/null @@ -1,112 +0,0 @@ -package services - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "log/slog" - "strings" - - "github.com/moby/moby/api/pkg/stdcopy" -) - -// streamMultiplexedLogs demultiplexes a Docker log stream (stdout/stderr) -// and sends lines to logsChan. Used by both container and swarm service logs. -func streamMultiplexedLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string) error { - stdoutReader, stdoutWriter := io.Pipe() - stderrReader, stderrWriter := io.Pipe() - - go func() { - defer func() { _ = stdoutWriter.Close() }() - defer func() { _ = stderrWriter.Close() }() - _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, logs) - if err != nil && !errors.Is(err, io.EOF) { - slog.Error("error demultiplexing logs", "error", err) - } - }() - - done := make(chan error, 2) - - go func() { - done <- readLogsFromReader(ctx, stdoutReader, logsChan, "stdout") - }() - - go func() { - done <- readLogsFromReader(ctx, stderrReader, logsChan, "stderr") - }() - - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-done: - if err != nil && !errors.Is(err, io.EOF) { - return err - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-done: - return nil - } - } -} - -// readLogsFromReader reads logs line by line from a reader and sends them to logsChan. -func readLogsFromReader(ctx context.Context, reader io.Reader, logsChan chan<- string, source string) error { - scanner := bufio.NewScanner(reader) - - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - line := scanner.Text() - if line != "" { - if source == "stderr" { - line = "[STDERR] " + line - } - - select { - case logsChan <- line: - case <-ctx.Done(): - return ctx.Err() - } - } - } - } - - return scanner.Err() -} - -// readAllLogs reads all logs at once (non-follow mode) and sends them to logsChan. -func readAllLogs(logs io.ReadCloser, logsChan chan<- string) error { - stdoutBuf := &strings.Builder{} - stderrBuf := &strings.Builder{} - - _, err := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) - if err != nil && !errors.Is(err, io.EOF) { - return fmt.Errorf("failed to demultiplex logs: %w", err) - } - - if stdoutBuf.Len() > 0 { - lines := strings.SplitSeq(strings.TrimRight(stdoutBuf.String(), "\n"), "\n") - for line := range lines { - if line != "" { - logsChan <- line - } - } - } - - if stderrBuf.Len() > 0 { - lines := strings.SplitSeq(strings.TrimRight(stderrBuf.String(), "\n"), "\n") - for line := range lines { - if line != "" { - logsChan <- "[STDERR] " + line - } - } - } - - return nil -} diff --git a/backend/internal/services/network_service.go b/backend/internal/services/network_service.go index eb76a9c988..14c85fceed 100644 --- a/backend/internal/services/network_service.go +++ b/backend/internal/services/network_service.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "log/slog" "sort" "strings" @@ -188,7 +189,7 @@ func (s *NetworkService) CreateNetwork(ctx context.Context, name string, options "name": name, } if logErr := s.eventService.LogNetworkEvent(ctx, models.EventTypeNetworkCreate, response.ID, name, user.ID, user.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log network creation action: %s\n", logErr) + slog.WarnContext(ctx, "could not log network creation action", "error", logErr) } warning := "" @@ -229,7 +230,7 @@ func (s *NetworkService) RemoveNetwork(ctx context.Context, id string, user mode "networkId": id, } if logErr := s.eventService.LogNetworkEvent(ctx, models.EventTypeNetworkDelete, id, networkName, user.ID, user.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log network delete action: %s\n", logErr) + slog.WarnContext(ctx, "could not log network delete action", "error", logErr) } return nil @@ -254,7 +255,7 @@ func (s *NetworkService) PruneNetworks(ctx context.Context) (*network.PruneRepor "networksDeleted": len(pruneReport.NetworksDeleted), } if logErr := s.eventService.LogNetworkEvent(ctx, models.EventTypeNetworkDelete, "", "bulk_prune", systemUser.ID, systemUser.Username, "0", metadata); logErr != nil { - fmt.Printf("Could not log network prune action: %s\n", logErr) + slog.WarnContext(ctx, "could not log network prune action", "error", logErr) } return &pruneReport, nil diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 4ef266e21a..5f0f2d2dce 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -14,8 +14,6 @@ import ( "text/template" "time" - "gorm.io/gorm" - "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -66,7 +64,6 @@ type NotificationService struct { db *database.DB config *config.Config environmentSvc *EnvironmentService - appriseService *AppriseService httpClient *http.Client } @@ -93,7 +90,6 @@ func NewNotificationService(db *database.DB, cfg *config.Config, environmentSvc db: db, config: cfg, environmentSvc: environmentSvc, - appriseService: NewAppriseService(db, cfg), httpClient: &http.Client{Timeout: 15 * time.Second}, } } @@ -132,7 +128,7 @@ func (s *NotificationService) resolveNotificationTargetInternal(ctx context.Cont func (s *NotificationService) resolveNotificationTargetForAccessTokenInternal(ctx context.Context, accessToken string) (NotificationTarget, error) { if s.environmentSvc == nil { - return NotificationTarget{}, fmt.Errorf("environment service not initialized") + return NotificationTarget{}, errors.New("environment service not initialized") } env, err := s.environmentSvc.ResolveEnvironmentByAccessToken(ctx, accessToken) @@ -156,10 +152,10 @@ func (s *NotificationService) resolveNotificationTargetForAccessTokenInternal(ct func (s *NotificationService) dispatchNotificationToManagerInternal(ctx context.Context, payload notificationdto.DispatchRequest) error { if s.config == nil || strings.TrimSpace(s.config.GetManagerBaseURL()) == "" { - return fmt.Errorf("manager API URL is required for notification dispatch") + return errors.New("manager API URL is required for notification dispatch") } if strings.TrimSpace(s.config.AgentToken) == "" { - return fmt.Errorf("agent token is required for notification dispatch") + return errors.New("agent token is required for notification dispatch") } body, err := json.Marshal(payload) @@ -173,9 +169,9 @@ func (s *NotificationService) dispatchNotificationToManagerInternal(ctx context. return fmt.Errorf("failed to create notification dispatch request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", s.config.AgentToken) + req.Header.Set("X-Api-Key", s.config.AgentToken) - resp, err := s.httpClient.Do(req) //nolint:gosec // dispatch URL is derived from validated manager configuration + resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to dispatch notification to manager: %w", err) } @@ -191,7 +187,7 @@ func (s *NotificationService) dispatchNotificationToManagerInternal(ctx context. func (s *NotificationService) DispatchNotification(ctx context.Context, accessToken string, payload notificationdto.DispatchRequest) error { if s.config != nil && s.config.AgentMode { - return fmt.Errorf("notification dispatch is manager-only") + return errors.New("notification dispatch is manager-only") } target, err := s.resolveNotificationTargetForAccessTokenInternal(ctx, accessToken) @@ -202,25 +198,25 @@ func (s *NotificationService) DispatchNotification(ctx context.Context, accessTo switch payload.Kind { case notificationdto.DispatchKindImageUpdate: if payload.ImageUpdate == nil { - return fmt.Errorf("image update payload is required") + return errors.New("image update payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendImageUpdateNotificationForTargetInternal(ctx, target, payload.ImageUpdate.ImageRef, &payload.ImageUpdate.UpdateInfo, models.NotificationEventImageUpdate) case notificationdto.DispatchKindBatchImageUpdate: if payload.BatchImageUpdate == nil { - return fmt.Errorf("batch image update payload is required") + return errors.New("batch image update payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendBatchImageUpdateNotificationForTargetInternal(ctx, target, payload.BatchImageUpdate.Updates) case notificationdto.DispatchKindContainerUpdate: if payload.ContainerUpdate == nil { - return fmt.Errorf("container update payload is required") + return errors.New("container update payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendContainerUpdateNotificationForTargetInternal(ctx, target, payload.ContainerUpdate.ContainerName, payload.ContainerUpdate.ImageRef, payload.ContainerUpdate.OldDigest, payload.ContainerUpdate.NewDigest) case notificationdto.DispatchKindVulnerabilityFound: if payload.VulnerabilityFound == nil { - return fmt.Errorf("vulnerability payload is required") + return errors.New("vulnerability payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendVulnerabilityNotificationForTargetInternal(ctx, target, VulnerabilityNotificationPayload{ @@ -234,13 +230,13 @@ func (s *NotificationService) DispatchNotification(ctx context.Context, accessTo }) case notificationdto.DispatchKindPruneReport: if payload.PruneReport == nil { - return fmt.Errorf("prune report payload is required") + return errors.New("prune report payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendPruneReportNotificationForTargetInternal(ctx, target, &payload.PruneReport.Result) case notificationdto.DispatchKindAutoHeal: if payload.AutoHeal == nil { - return fmt.Errorf("auto-heal payload is required") + return errors.New("auto-heal payload is required") } logManagerDispatchNotificationInternal(ctx, target, payload.Kind) return s.sendAutoHealNotificationForTargetInternal(ctx, target, payload.AutoHeal.ContainerName, payload.AutoHeal.ContainerID) @@ -303,7 +299,7 @@ func (s *NotificationService) DeleteSettings(ctx context.Context, provider model func (s *NotificationService) SendImageUpdateNotification(ctx context.Context, imageRef string, updateInfo *imageupdate.Response, eventType models.NotificationEventType) error { if updateInfo == nil { - return fmt.Errorf("updateInfo is required") + return errors.New("updateInfo is required") } if s.config != nil && s.config.AgentMode { @@ -325,11 +321,6 @@ func (s *NotificationService) SendImageUpdateNotification(ctx context.Context, i } func (s *NotificationService) sendImageUpdateNotificationForTargetInternal(ctx context.Context, target NotificationTarget, imageRef string, updateInfo *imageupdate.Response, eventType models.NotificationEventType) error { - // Send to Apprise if enabled (don't block on error) - if appriseErr := s.appriseService.SendImageUpdateNotification(ctx, target.EnvironmentName, imageRef, updateInfo); appriseErr != nil { - slog.WarnContext(ctx, "Failed to send Apprise notification", "error", appriseErr) - } - settings, err := s.GetAllSettings(ctx) if err != nil { return fmt.Errorf("failed to get notification settings: %w", err) @@ -445,11 +436,6 @@ func (s *NotificationService) SendContainerUpdateNotification(ctx context.Contex } func (s *NotificationService) sendContainerUpdateNotificationForTargetInternal(ctx context.Context, target NotificationTarget, containerName, imageRef, oldDigest, newDigest string) error { - // Send to Apprise if enabled (don't block on error) - if appriseErr := s.appriseService.SendContainerUpdateNotification(ctx, target.EnvironmentName, containerName, imageRef, oldDigest, newDigest); appriseErr != nil { - slog.WarnContext(ctx, "Failed to send Apprise notification", "error", appriseErr) - } - settings, err := s.GetAllSettings(ctx) if err != nil { return fmt.Errorf("failed to get notification settings: %w", err) @@ -567,41 +553,13 @@ func (s *NotificationService) sendVulnerabilityNotificationForTargetInternal(ctx continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendDiscordVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendEmailVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendTelegramVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendSignalVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendSlackVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendNtfyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendPushoverVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendGotifyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendMatrixVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendGenericVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, payload, setting.Config, s.vulnerabilityNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errors, setting.Provider, sendErr) s.logNotification(ctx, setting.Provider, payload.ImageName, status, errMsg, models.JSON{ "cveId": payload.CVEID, @@ -628,16 +586,16 @@ func (s *NotificationService) sendDiscordNotification(ctx context.Context, envir } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } // Decrypt token if encrypted if discordConfig.Token != "" { - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Discord token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + discordConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -665,19 +623,19 @@ func (s *NotificationService) sendTelegramNotification(ctx context.Context, envi } if telegramConfig.BotToken == "" { - return fmt.Errorf("telegram bot token not configured") + return errors.New("telegram bot token not configured") } if len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } // Decrypt bot token if encrypted if telegramConfig.BotToken != "" { - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { - telegramConfig.BotToken = decrypted - } else { - slog.Warn("Failed to decrypt Telegram bot token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + telegramConfig.BotToken = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -710,10 +668,10 @@ func (s *NotificationService) sendEmailNotification(ctx context.Context, environ } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -726,11 +684,11 @@ func (s *NotificationService) sendEmailNotification(ctx context.Context, environ } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderEmailTemplate(environmentName, imageRef, updateInfo) @@ -738,7 +696,7 @@ func (s *NotificationService) sendEmailNotification(ctx context.Context, environ return fmt.Errorf("failed to render email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Container Update Available: %s", notifications.SanitizeForEmail(imageRef))) + subject := notifications.BuildEmailSubject(environmentName, "Container Update Available: "+notifications.SanitizeForEmail(imageRef)) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -805,16 +763,16 @@ func (s *NotificationService) sendDiscordContainerUpdateNotification(ctx context } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } // Decrypt token if encrypted if discordConfig.Token != "" { - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Discord token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + discordConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -844,19 +802,19 @@ func (s *NotificationService) sendTelegramContainerUpdateNotification(ctx contex } if telegramConfig.BotToken == "" { - return fmt.Errorf("telegram bot token not configured") + return errors.New("telegram bot token not configured") } if len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } // Decrypt bot token if encrypted if telegramConfig.BotToken != "" { - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { - telegramConfig.BotToken = decrypted - } else { - slog.Warn("Failed to decrypt Telegram bot token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + telegramConfig.BotToken = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -891,10 +849,10 @@ func (s *NotificationService) sendEmailContainerUpdateNotification(ctx context.C } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -907,11 +865,11 @@ func (s *NotificationService) sendEmailContainerUpdateNotification(ctx context.C } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderContainerUpdateEmailTemplate(environmentName, containerName, imageRef, oldDigest, newDigest) @@ -919,7 +877,7 @@ func (s *NotificationService) sendEmailContainerUpdateNotification(ctx context.C return fmt.Errorf("failed to render email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Container Updated: %s", notifications.SanitizeForEmail(containerName))) + subject := notifications.BuildEmailSubject(environmentName, "Container Updated: "+notifications.SanitizeForEmail(containerName)) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -995,64 +953,26 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI // Test vulnerability notification (all providers) if testType == notificationTestTypeVulnerability { payload := VulnerabilityNotificationPayload{ - CVEID: fmt.Sprintf("Daily Summary - %s", time.Now().UTC().Format("2006-01-02")), + CVEID: "Daily Summary - " + time.Now().UTC().Format("2006-01-02"), Severity: "Critical:1 High:3 Medium:2 Low:1 Unknown:0", ImageName: "5 image(s) scanned, 2 with fixable vulnerabilities", FixedVersion: "7 fixable vulnerability record(s)", PkgName: "CVE-2025-1234, CVE-2025-5678, CVE-2026-0001", } - switch provider { - case models.NotificationProviderDiscord: - return s.sendDiscordVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderEmail: - return s.sendEmailVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderTelegram: - return s.sendTelegramVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSignal: - return s.sendSignalVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderSlack: - return s.sendSlackVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderNtfy: - return s.sendNtfyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderPushover: - return s.sendPushoverVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGotify: - return s.sendGotifyVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderMatrix: - return s.sendMatrixVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - case models.NotificationProviderGeneric: - return s.sendGenericVulnerabilityNotification(ctx, target.EnvironmentName, payload, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, payload, setting.Config, s.vulnerabilityNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } if testType == notificationTestTypeAutoHeal { testContainerName := "test-container" - switch provider { - case models.NotificationProviderDiscord: - return s.sendDiscordAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderEmail: - return s.sendEmailAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderTelegram: - return s.sendTelegramAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderSignal: - return s.sendSignalAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderSlack: - return s.sendSlackAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderNtfy: - return s.sendNtfyAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderPushover: - return s.sendPushoverAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderGotify: - return s.sendGotifyAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderMatrix: - return s.sendMatrixAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - case models.NotificationProviderGeneric: - return s.sendGenericAutoHealNotification(ctx, target.EnvironmentName, testContainerName, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, testContainerName, setting.Config, s.autoHealNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } if testType == notificationTestTypePruneReport { @@ -1070,30 +990,11 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI Errors: []string{}, } - switch provider { - case models.NotificationProviderDiscord: - return s.sendDiscordPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderEmail: - return s.sendEmailPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderTelegram: - return s.sendTelegramPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSignal: - return s.sendSignalPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSlack: - return s.sendSlackPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderNtfy: - return s.sendNtfyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderPushover: - return s.sendPushoverPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGotify: - return s.sendGotifyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderMatrix: - return s.sendMatrixPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGeneric: - return s.sendGenericPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, result, setting.Config, s.pruneReportNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } testUpdate := &imageupdate.Response{ @@ -1133,30 +1034,11 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI ResponseTimeMs: 95, }, } - switch provider { - case models.NotificationProviderDiscord: - return s.sendBatchDiscordNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderEmail: - return s.sendBatchEmailNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderTelegram: - return s.sendBatchTelegramNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderSignal: - return s.sendBatchSignalNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderSlack: - return s.sendBatchSlackNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderNtfy: - return s.sendBatchNtfyNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderPushover: - return s.sendBatchPushoverNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderGotify: - return s.sendBatchGotifyNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderMatrix: - return s.sendBatchMatrixNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - case models.NotificationProviderGeneric: - return s.sendBatchGenericNotification(ctx, target.EnvironmentName, testUpdates, setting.Config) - default: - return fmt.Errorf("unknown provider: %s", provider) + handled, sendErr := sendProviderNotificationInternal(ctx, provider, target.EnvironmentName, testUpdates, setting.Config, s.batchImageUpdateNotificationSendersInternal()) + if !handled { + return unknownNotificationProviderErrorInternal(provider) } + return sendErr } imageRef := "nginx:latest" @@ -1193,6 +1075,129 @@ func (s *NotificationService) TestNotification(ctx context.Context, environmentI } } +type notificationProviderSenderInternal[T any] func(context.Context, string, T, models.JSON) error + +type notificationProviderSendersInternal[T any] struct { + Discord notificationProviderSenderInternal[T] + Email notificationProviderSenderInternal[T] + Telegram notificationProviderSenderInternal[T] + Signal notificationProviderSenderInternal[T] + Slack notificationProviderSenderInternal[T] + Ntfy notificationProviderSenderInternal[T] + Pushover notificationProviderSenderInternal[T] + Gotify notificationProviderSenderInternal[T] + Matrix notificationProviderSenderInternal[T] + Generic notificationProviderSenderInternal[T] +} + +func (s *NotificationService) vulnerabilityNotificationSendersInternal() notificationProviderSendersInternal[VulnerabilityNotificationPayload] { + return notificationProviderSendersInternal[VulnerabilityNotificationPayload]{ + Discord: s.sendDiscordVulnerabilityNotification, + Email: s.sendEmailVulnerabilityNotification, + Telegram: s.sendTelegramVulnerabilityNotification, + Signal: s.sendSignalVulnerabilityNotification, + Slack: s.sendSlackVulnerabilityNotification, + Ntfy: s.sendNtfyVulnerabilityNotification, + Pushover: s.sendPushoverVulnerabilityNotification, + Gotify: s.sendGotifyVulnerabilityNotification, + Matrix: s.sendMatrixVulnerabilityNotification, + Generic: s.sendGenericVulnerabilityNotification, + } +} + +func (s *NotificationService) batchImageUpdateNotificationSendersInternal() notificationProviderSendersInternal[map[string]*imageupdate.Response] { + return notificationProviderSendersInternal[map[string]*imageupdate.Response]{ + Discord: s.sendBatchDiscordNotification, + Email: s.sendBatchEmailNotification, + Telegram: s.sendBatchTelegramNotification, + Signal: s.sendBatchSignalNotification, + Slack: s.sendBatchSlackNotification, + Ntfy: s.sendBatchNtfyNotification, + Pushover: s.sendBatchPushoverNotification, + Gotify: s.sendBatchGotifyNotification, + Matrix: s.sendBatchMatrixNotification, + Generic: s.sendBatchGenericNotification, + } +} + +func (s *NotificationService) pruneReportNotificationSendersInternal() notificationProviderSendersInternal[*system.PruneAllResult] { + return notificationProviderSendersInternal[*system.PruneAllResult]{ + Discord: s.sendDiscordPruneNotification, + Email: s.sendEmailPruneNotification, + Telegram: s.sendTelegramPruneNotification, + Signal: s.sendSignalPruneNotification, + Slack: s.sendSlackPruneNotification, + Ntfy: s.sendNtfyPruneNotification, + Pushover: s.sendPushoverPruneNotification, + Gotify: s.sendGotifyPruneNotification, + Matrix: s.sendMatrixPruneNotification, + Generic: s.sendGenericPruneNotification, + } +} + +func (s *NotificationService) autoHealNotificationSendersInternal() notificationProviderSendersInternal[string] { + return notificationProviderSendersInternal[string]{ + Discord: s.sendDiscordAutoHealNotification, + Email: s.sendEmailAutoHealNotification, + Telegram: s.sendTelegramAutoHealNotification, + Signal: s.sendSignalAutoHealNotification, + Slack: s.sendSlackAutoHealNotification, + Ntfy: s.sendNtfyAutoHealNotification, + Pushover: s.sendPushoverAutoHealNotification, + Gotify: s.sendGotifyAutoHealNotification, + Matrix: s.sendMatrixAutoHealNotification, + Generic: s.sendGenericAutoHealNotification, + } +} + +func sendProviderNotificationInternal[T any]( + ctx context.Context, + provider models.NotificationProvider, + environmentName string, + payload T, + config models.JSON, + senders notificationProviderSendersInternal[T], +) (bool, error) { + switch provider { + case models.NotificationProviderDiscord: + return true, senders.Discord(ctx, environmentName, payload, config) + case models.NotificationProviderEmail: + return true, senders.Email(ctx, environmentName, payload, config) + case models.NotificationProviderTelegram: + return true, senders.Telegram(ctx, environmentName, payload, config) + case models.NotificationProviderSignal: + return true, senders.Signal(ctx, environmentName, payload, config) + case models.NotificationProviderSlack: + return true, senders.Slack(ctx, environmentName, payload, config) + case models.NotificationProviderNtfy: + return true, senders.Ntfy(ctx, environmentName, payload, config) + case models.NotificationProviderPushover: + return true, senders.Pushover(ctx, environmentName, payload, config) + case models.NotificationProviderGotify: + return true, senders.Gotify(ctx, environmentName, payload, config) + case models.NotificationProviderMatrix: + return true, senders.Matrix(ctx, environmentName, payload, config) + case models.NotificationProviderGeneric: + return true, senders.Generic(ctx, environmentName, payload, config) + default: + return false, nil + } +} + +func collectNotificationSendResultInternal(errors *[]string, provider models.NotificationProvider, sendErr error) (string, *string) { + if sendErr == nil { + return "success", nil + } + + msg := sendErr.Error() + *errors = append(*errors, fmt.Sprintf("%s: %s", provider, msg)) + return "failed", &msg +} + +func unknownNotificationProviderErrorInternal(provider models.NotificationProvider) error { + return fmt.Errorf("unknown provider: %s", provider) +} + func (s *NotificationService) sendTestEmail(ctx context.Context, environmentName string, config models.JSON) error { var emailConfig models.EmailConfig configBytes, err := json.Marshal(config) @@ -1204,10 +1209,10 @@ func (s *NotificationService) sendTestEmail(ctx context.Context, environmentName } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -1220,9 +1225,11 @@ func (s *NotificationService) sendTestEmail(ctx context.Context, environmentName } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderTestEmailTemplate(environmentName) @@ -1335,11 +1342,6 @@ func (s *NotificationService) sendBatchImageUpdateNotificationForTargetInternal( return nil } - // Send to Apprise if enabled - if appriseErr := s.appriseService.SendBatchImageUpdateNotification(ctx, target.EnvironmentName, updatesWithChanges); appriseErr != nil { - slog.WarnContext(ctx, "Failed to send Apprise notification", "error", appriseErr) - } - settings, err := s.GetAllSettings(ctx) if err != nil { return fmt.Errorf("failed to get notification settings: %w", err) @@ -1355,41 +1357,13 @@ func (s *NotificationService) sendBatchImageUpdateNotificationForTargetInternal( continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendBatchDiscordNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendBatchEmailNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendBatchTelegramNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendBatchSignalNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendBatchSlackNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendBatchNtfyNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendBatchPushoverNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendBatchGotifyNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendBatchMatrixNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendBatchGenericNotification(ctx, target.EnvironmentName, updatesWithChanges, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, updatesWithChanges, setting.Config, s.batchImageUpdateNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errors, setting.Provider, sendErr) imageRefs := make([]string, 0, len(updatesWithChanges)) for ref := range updatesWithChanges { @@ -1411,18 +1385,12 @@ func (s *NotificationService) sendBatchImageUpdateNotificationForTargetInternal( } func (s *NotificationService) sendBatchDiscordNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var discordConfig models.DiscordConfig - configBytes, err := json.Marshal(config) + discordConfig, err := decodeNotificationConfigInternal[models.DiscordConfig](config, "discord") if err != nil { - return fmt.Errorf("failed to marshal discord config: %w", err) - } - if err := json.Unmarshal(configBytes, &discordConfig); err != nil { - return fmt.Errorf("failed to unmarshal discord config: %w", err) + return err } - - // Decrypt token if encrypted - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted + if err := decryptStringCredentialInternal(&discordConfig.Token); err != nil { + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1448,8 +1416,12 @@ func (s *NotificationService) sendBatchTelegramNotification(ctx context.Context, return fmt.Errorf("failed to unmarshal telegram config: %w", err) } - // Decrypt bot token if encrypted - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { + // Decrypt bot token + if telegramConfig.BotToken != "" { + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) + } telegramConfig.BotToken = decrypted } @@ -1482,10 +1454,10 @@ func (s *NotificationService) sendBatchEmailNotification(ctx context.Context, en } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { @@ -1498,11 +1470,11 @@ func (s *NotificationService) sendBatchEmailNotification(ctx context.Context, en } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderBatchEmailTemplate(environmentName, updates) @@ -1586,42 +1558,42 @@ func (s *NotificationService) sendSignalNotification(ctx context.Context, enviro } if signalConfig.Host == "" { - return fmt.Errorf("signal host not configured") + return errors.New("signal host not configured") } if signalConfig.Port == 0 { - return fmt.Errorf("signal port not configured") + return errors.New("signal port not configured") } if signalConfig.Source == "" { - return fmt.Errorf("signal source phone number not configured") + return errors.New("signal source phone number not configured") } if len(signalConfig.Recipients) == 0 { - return fmt.Errorf("no signal recipients configured") + return errors.New("no signal recipients configured") } // Validate authentication hasBasicAuth := signalConfig.User != "" && signalConfig.Password != "" hasTokenAuth := signalConfig.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } // Decrypt sensitive fields if encrypted if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Signal password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Signal token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildImageUpdateNotificationMessage( @@ -1649,42 +1621,42 @@ func (s *NotificationService) sendSignalContainerUpdateNotification(ctx context. } if signalConfig.Host == "" { - return fmt.Errorf("signal host not configured") + return errors.New("signal host not configured") } if signalConfig.Port == 0 { - return fmt.Errorf("signal port not configured") + return errors.New("signal port not configured") } if signalConfig.Source == "" { - return fmt.Errorf("signal source phone number not configured") + return errors.New("signal source phone number not configured") } if len(signalConfig.Recipients) == 0 { - return fmt.Errorf("no signal recipients configured") + return errors.New("no signal recipients configured") } // Validate authentication hasBasicAuth := signalConfig.User != "" && signalConfig.Password != "" hasTokenAuth := signalConfig.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } // Decrypt sensitive fields if encrypted if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Signal password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Signal token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1717,22 +1689,26 @@ func (s *NotificationService) sendBatchSignalNotification(ctx context.Context, e hasBasicAuth := signalConfig.User != "" && signalConfig.Password != "" hasTokenAuth := signalConfig.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } // Decrypt sensitive fields if encrypted if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1749,26 +1725,9 @@ func (s *NotificationService) sendBatchSignalNotification(ctx context.Context, e } func (s *NotificationService) sendSlackNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "Slack", true) if err != nil { - return fmt.Errorf("failed to marshal Slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal Slack config: %w", err) - } - - if slackConfig.Token == "" { - return fmt.Errorf("slack token not configured") - } - - // Decrypt token if encrypted - if slackConfig.Token != "" { - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Slack token, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -1786,26 +1745,9 @@ func (s *NotificationService) sendSlackNotification(ctx context.Context, environ } func (s *NotificationService) sendSlackContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "Slack", true) if err != nil { - return fmt.Errorf("failed to marshal Slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal Slack config: %w", err) - } - - if slackConfig.Token == "" { - return fmt.Errorf("slack token not configured") - } - - // Decrypt token if encrypted - if slackConfig.Token != "" { - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Slack token, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1825,18 +1767,9 @@ func (s *NotificationService) sendSlackContainerUpdateNotification(ctx context.C } func (s *NotificationService) sendBatchSlackNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "slack", false) if err != nil { - return fmt.Errorf("failed to marshal slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal slack config: %w", err) - } - - // Decrypt token if encrypted - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1853,26 +1786,9 @@ func (s *NotificationService) sendBatchSlackNotification(ctx context.Context, en } func (s *NotificationService) sendNtfyNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "Ntfy", true) if err != nil { - return fmt.Errorf("failed to marshal Ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Ntfy config: %w", err) - } - - if ntfyConfig.Topic == "" { - return fmt.Errorf("ntfy topic is required") - } - - // Decrypt password if encrypted - if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Ntfy password, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -1890,26 +1806,9 @@ func (s *NotificationService) sendNtfyNotification(ctx context.Context, environm } func (s *NotificationService) sendNtfyContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "Ntfy", true) if err != nil { - return fmt.Errorf("failed to marshal Ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Ntfy config: %w", err) - } - - if ntfyConfig.Topic == "" { - return fmt.Errorf("ntfy topic is required") - } - - // Decrypt password if encrypted - if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted - } else { - slog.Warn("Failed to decrypt Ntfy password, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -1929,20 +1828,9 @@ func (s *NotificationService) sendNtfyContainerUpdateNotification(ctx context.Co } func (s *NotificationService) sendBatchNtfyNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "ntfy", false) if err != nil { - return fmt.Errorf("failed to marshal ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal ntfy config: %w", err) - } - - // Decrypt password if encrypted - if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted - } + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -1959,28 +1847,16 @@ func (s *NotificationService) sendBatchNtfyNotification(ctx context.Context, env } func (s *NotificationService) sendPushoverNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "Pushover") if err != nil { - return fmt.Errorf("failed to marshal Pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal Pushover config: %w", err) + return err } if pushoverConfig.Token == "" { - return fmt.Errorf("pushover API token not configured") + return errors.New("pushover API token not configured") } if pushoverConfig.User == "" { - return fmt.Errorf("pushover user key not configured") - } - - if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Pushover token, using raw value (may be unencrypted legacy value)", "error", err) - } + return errors.New("pushover user key not configured") } message := notifications.BuildImageUpdateNotificationMessage( @@ -1998,28 +1874,16 @@ func (s *NotificationService) sendPushoverNotification(ctx context.Context, envi } func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "Pushover") if err != nil { - return fmt.Errorf("failed to marshal Pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal Pushover config: %w", err) + return err } if pushoverConfig.Token == "" { - return fmt.Errorf("pushover API token not configured") + return errors.New("pushover API token not configured") } if pushoverConfig.User == "" { - return fmt.Errorf("pushover user key not configured") - } - - if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Pushover token, using raw value (may be unencrypted legacy value)", "error", err) - } + return errors.New("pushover user key not configured") } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2039,19 +1903,9 @@ func (s *NotificationService) sendPushoverContainerUpdateNotification(ctx contex } func (s *NotificationService) sendBatchPushoverNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "pushover") if err != nil { - return fmt.Errorf("failed to marshal pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal pushover config: %w", err) - } - - if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted - } + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2078,7 +1932,7 @@ func (s *NotificationService) sendGenericNotification(ctx context.Context, envir } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } message := notifications.BuildImageUpdateNotificationMessage( @@ -2107,7 +1961,7 @@ func (s *NotificationService) sendGenericContainerUpdateNotification(ctx context } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2179,10 +2033,10 @@ func (s *NotificationService) sendEmailVulnerabilityNotification(ctx context.Con return fmt.Errorf("failed to unmarshal email config: %w", err) } if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(emailConfig.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") } if _, err := mail.ParseAddress(emailConfig.FromAddress); err != nil { return fmt.Errorf("invalid from address: %w", err) @@ -2193,17 +2047,17 @@ func (s *NotificationService) sendEmailVulnerabilityNotification(ctx context.Con } } if emailConfig.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword); err == nil { - emailConfig.SMTPPassword = decrypted - } else { - slog.Warn("Failed to decrypt email SMTP password, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(emailConfig.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + emailConfig.SMTPPassword = decrypted } htmlBody, _, err := s.renderVulnerabilitySummaryEmailTemplate(environmentName, payload) if err != nil { return fmt.Errorf("failed to render summary email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Daily Vulnerability Summary: %s", notifications.SanitizeForEmail(payload.CVEID))) + subject := notifications.BuildEmailSubject(environmentName, "Daily Vulnerability Summary: "+notifications.SanitizeForEmail(payload.CVEID)) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -2220,14 +2074,14 @@ func (s *NotificationService) sendDiscordVulnerabilityNotification(ctx context.C return fmt.Errorf("failed to unmarshal Discord config: %w", err) } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } if discordConfig.Token != "" { - if decrypted, err := crypto.Decrypt(discordConfig.Token); err == nil { - discordConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Discord token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(discordConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + discordConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatMarkdown, @@ -2254,17 +2108,17 @@ func (s *NotificationService) sendTelegramVulnerabilityNotification(ctx context. return fmt.Errorf("failed to unmarshal Telegram config: %w", err) } if telegramConfig.BotToken == "" { - return fmt.Errorf("telegram bot token not configured") + return errors.New("telegram bot token not configured") } if len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } if telegramConfig.BotToken != "" { - if decrypted, err := crypto.Decrypt(telegramConfig.BotToken); err == nil { - telegramConfig.BotToken = decrypted - } else { - slog.Warn("Failed to decrypt Telegram bot token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(telegramConfig.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + telegramConfig.BotToken = decrypted } if telegramConfig.ParseMode == "" { telegramConfig.ParseMode = "HTML" @@ -2294,17 +2148,21 @@ func (s *NotificationService) sendSignalVulnerabilityNotification(ctx context.Co return fmt.Errorf("failed to unmarshal Signal config: %w", err) } if signalConfig.Host == "" || signalConfig.Port == 0 || signalConfig.Source == "" || len(signalConfig.Recipients) == 0 { - return fmt.Errorf("signal not fully configured") + return errors.New("signal not fully configured") } if signalConfig.Password != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Password); err == nil { - signalConfig.Password = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Password) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Password = decrypted } if signalConfig.Token != "" { - if decrypted, err := crypto.Decrypt(signalConfig.Token); err == nil { - signalConfig.Token = decrypted + decrypted, err := crypto.Decrypt(signalConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + signalConfig.Token = decrypted } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2322,33 +2180,11 @@ func (s *NotificationService) sendSignalVulnerabilityNotification(ctx context.Co } func (s *NotificationService) sendSlackVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var slackConfig models.SlackConfig - configBytes, err := json.Marshal(config) + slackConfig, err := prepareSlackConfigInternal(config, "Slack", true) if err != nil { - return fmt.Errorf("failed to marshal Slack config: %w", err) - } - if err := json.Unmarshal(configBytes, &slackConfig); err != nil { - return fmt.Errorf("failed to unmarshal Slack config: %w", err) - } - if slackConfig.Token == "" { - return fmt.Errorf("slack token not configured") - } - if slackConfig.Token != "" { - if decrypted, err := crypto.Decrypt(slackConfig.Token); err == nil { - slackConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Slack token, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatSlack, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatSlack, environmentName, payload) if err := notifications.SendSlack(ctx, slackConfig, message); err != nil { return fmt.Errorf("failed to send Slack notification: %w", err) } @@ -2356,31 +2192,11 @@ func (s *NotificationService) sendSlackVulnerabilityNotification(ctx context.Con } func (s *NotificationService) sendNtfyVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var ntfyConfig models.NtfyConfig - configBytes, err := json.Marshal(config) + ntfyConfig, err := prepareNtfyConfigInternal(config, "Ntfy", true) if err != nil { - return fmt.Errorf("failed to marshal Ntfy config: %w", err) - } - if err := json.Unmarshal(configBytes, &ntfyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Ntfy config: %w", err) - } - if ntfyConfig.Topic == "" { - return fmt.Errorf("ntfy topic is required") - } - if ntfyConfig.Password != "" { - if decrypted, err := crypto.Decrypt(ntfyConfig.Password); err == nil { - ntfyConfig.Password = decrypted - } + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if err := notifications.SendNtfy(ctx, ntfyConfig, message); err != nil { return fmt.Errorf("failed to send Ntfy notification: %w", err) } @@ -2388,31 +2204,14 @@ func (s *NotificationService) sendNtfyVulnerabilityNotification(ctx context.Cont } func (s *NotificationService) sendPushoverVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var pushoverConfig models.PushoverConfig - configBytes, err := json.Marshal(config) + pushoverConfig, err := preparePushoverConfigInternal(config, "Pushover") if err != nil { - return fmt.Errorf("failed to marshal Pushover config: %w", err) - } - if err := json.Unmarshal(configBytes, &pushoverConfig); err != nil { - return fmt.Errorf("failed to unmarshal Pushover config: %w", err) + return err } if pushoverConfig.Token == "" || pushoverConfig.User == "" { - return fmt.Errorf("pushover token or user not configured") - } - if pushoverConfig.Token != "" { - if decrypted, err := crypto.Decrypt(pushoverConfig.Token); err == nil { - pushoverConfig.Token = decrypted - } + return errors.New("pushover token or user not configured") } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if pushoverConfig.Title == "" { pushoverConfig.Title = notifications.BuildEmailSubject(environmentName, "Daily Vulnerability Summary") } @@ -2423,28 +2222,11 @@ func (s *NotificationService) sendPushoverVulnerabilityNotification(ctx context. } func (s *NotificationService) sendGotifyVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "Gotify") if err != nil { - return fmt.Errorf("failed to marshal Gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Gotify config: %w", err) - } - if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if gotifyConfig.Title == "" { gotifyConfig.Title = notifications.BuildEmailSubject(environmentName, "Daily Vulnerability Summary") } @@ -2455,28 +2237,11 @@ func (s *NotificationService) sendGotifyVulnerabilityNotification(ctx context.Co } func (s *NotificationService) sendMatrixVulnerabilityNotification(ctx context.Context, environmentName string, payload VulnerabilityNotificationPayload, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted - } + return err } - message := notifications.BuildVulnerabilitySummaryNotificationMessage( - notifications.MessageFormatPlain, - environmentName, - payload.CVEID, - payload.ImageName, - payload.FixedVersion, - payload.Severity, - payload.PkgName, - ) + message := buildVulnerabilitySummaryMessageInternal(notifications.MessageFormatPlain, environmentName, payload) if err := notifications.SendMatrix(ctx, matrixConfig, message); err != nil { return fmt.Errorf("failed to send Matrix notification: %w", err) } @@ -2493,7 +2258,7 @@ func (s *NotificationService) sendGenericVulnerabilityNotification(ctx context.C return fmt.Errorf("failed to unmarshal Generic config: %w", err) } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } message := notifications.BuildVulnerabilitySummaryNotificationMessage( notifications.MessageFormatPlain, @@ -2522,7 +2287,7 @@ func (s *NotificationService) sendBatchGenericNotification(ctx context.Context, } if genericConfig.WebhookURL == "" { - return fmt.Errorf("webhook URL not configured") + return errors.New("webhook URL not configured") } title := "Container Image Updates Available" @@ -2540,21 +2305,9 @@ func (s *NotificationService) sendBatchGenericNotification(ctx context.Context, } func (s *NotificationService) sendGotifyNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "Gotify") if err != nil { - return fmt.Errorf("failed to marshal Gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Gotify config: %w", err) - } - - if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -2572,21 +2325,9 @@ func (s *NotificationService) sendGotifyNotification(ctx context.Context, enviro } func (s *NotificationService) sendGotifyContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "Gotify") if err != nil { - return fmt.Errorf("failed to marshal Gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal Gotify config: %w", err) - } - - if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) - } + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2606,19 +2347,9 @@ func (s *NotificationService) sendGotifyContainerUpdateNotification(ctx context. } func (s *NotificationService) sendBatchGotifyNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var gotifyConfig models.GotifyConfig - configBytes, err := json.Marshal(config) + gotifyConfig, err := prepareGotifyConfigInternal(config, "gotify") if err != nil { - return fmt.Errorf("failed to marshal gotify config: %w", err) - } - if err := json.Unmarshal(configBytes, &gotifyConfig); err != nil { - return fmt.Errorf("failed to unmarshal gotify config: %w", err) - } - - if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2635,19 +2366,9 @@ func (s *NotificationService) sendBatchGotifyNotification(ctx context.Context, e } func (s *NotificationService) sendMatrixNotification(ctx context.Context, environmentName, imageRef string, updateInfo *imageupdate.Response, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - - if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted - } + return err } message := notifications.BuildImageUpdateNotificationMessage( @@ -2665,19 +2386,9 @@ func (s *NotificationService) sendMatrixNotification(ctx context.Context, enviro } func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context.Context, environmentName, containerName, imageRef, oldDigest, newDigest string, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - - if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted - } + return err } message := notifications.BuildContainerUpdateNotificationMessage( @@ -2697,19 +2408,9 @@ func (s *NotificationService) sendMatrixContainerUpdateNotification(ctx context. } func (s *NotificationService) sendBatchMatrixNotification(ctx context.Context, environmentName string, updates map[string]*imageupdate.Response, config models.JSON) error { - var matrixConfig models.MatrixConfig - configBytes, err := json.Marshal(config) + matrixConfig, err := prepareMatrixConfigInternal(config) if err != nil { - return fmt.Errorf("failed to marshal Matrix config: %w", err) - } - if err := json.Unmarshal(configBytes, &matrixConfig); err != nil { - return fmt.Errorf("failed to unmarshal Matrix config: %w", err) - } - - if matrixConfig.Password != "" { - if decrypted, err := crypto.Decrypt(matrixConfig.Password); err == nil { - matrixConfig.Password = decrypted - } + return err } message := notifications.BuildBatchImageUpdateNotificationMessage( @@ -2725,103 +2426,6 @@ func (s *NotificationService) sendBatchMatrixNotification(ctx context.Context, e return nil } -// MigrateDiscordWebhookUrlToFields migrates legacy Discord webhookUrl to separate webhookId and token fields. -// This should be called during bootstrap to ensure existing Discord configurations are preserved. -func (s *NotificationService) MigrateDiscordWebhookUrlToFields(ctx context.Context) error { - var setting models.NotificationSettings - err := s.db.WithContext(ctx).Where("provider = ?", models.NotificationProviderDiscord).First(&setting).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // No Discord config exists, nothing to migrate - return nil - } - return fmt.Errorf("failed to query Discord settings: %w", err) - } - - var discordConfig models.DiscordConfig - configBytes, err := json.Marshal(setting.Config) - if err != nil { - return fmt.Errorf("failed to marshal Discord config: %w", err) - } - if err := json.Unmarshal(configBytes, &discordConfig); err != nil { - slog.WarnContext(ctx, "Failed to parse Discord config for migration", "error", err) - return nil - } - - // Check if already migrated (has webhookId and token) - if discordConfig.WebhookID != "" && discordConfig.Token != "" { - slog.DebugContext(ctx, "Discord config already migrated, skipping") - return nil - } - - // Check for legacy webhookUrl field - var legacyConfig struct { - WebhookUrl string `json:"webhookUrl"` - Username string `json:"username,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` - Events map[models.NotificationEventType]bool `json:"events,omitempty"` - } - if err := json.Unmarshal(configBytes, &legacyConfig); err != nil { - slog.WarnContext(ctx, "Failed to parse legacy Discord config structure", "error", err) - return nil - } - - if legacyConfig.WebhookUrl == "" { - slog.DebugContext(ctx, "No legacy webhookUrl to migrate") - return nil - } - - // Parse webhook URL: https://discord.com/api/webhooks/{id}/{token} - parts := strings.Split(legacyConfig.WebhookUrl, "/webhooks/") - if len(parts) != 2 { - slog.WarnContext(ctx, "Invalid Discord webhook URL format, skipping migration", "url", legacyConfig.WebhookUrl) - return nil - } - - webhookParts := strings.Split(parts[1], "/") - if len(webhookParts) != 2 { - slog.WarnContext(ctx, "Invalid Discord webhook URL format, skipping migration", "url", legacyConfig.WebhookUrl) - return nil - } - - webhookID := webhookParts[0] - token := webhookParts[1] - - slog.InfoContext(ctx, "Migrating legacy Discord webhookUrl to webhookId and token") - - // Encrypt token before storing - encryptedToken, err := crypto.Encrypt(token) - if err != nil { - return fmt.Errorf("failed to encrypt Discord token: %w", err) - } - - // Update with new structure - newConfig := models.DiscordConfig{ - WebhookID: webhookID, - Token: encryptedToken, - Username: legacyConfig.Username, - AvatarURL: legacyConfig.AvatarURL, - Events: legacyConfig.Events, - } - - var configMap models.JSON - newConfigBytes, err := json.Marshal(newConfig) - if err != nil { - return fmt.Errorf("failed to marshal new Discord config: %w", err) - } - if err = json.Unmarshal(newConfigBytes, &configMap); err != nil { - return fmt.Errorf("failed to unmarshal new Discord config to JSON: %w", err) - } - - setting.Config = configMap - if err = s.db.WithContext(ctx).Save(&setting).Error; err != nil { - return fmt.Errorf("failed to save migrated Discord config: %w", err) - } - - slog.InfoContext(ctx, "Successfully migrated Discord config") - return nil -} - func (s *NotificationService) SendPruneReportNotification(ctx context.Context, result *system.PruneAllResult) error { hasChanges := pruneResultHasChangesInternal(result) hasErrors := result != nil && len(result.Errors) > 0 @@ -2866,41 +2470,13 @@ func (s *NotificationService) sendPruneReportNotificationForTargetInternal(ctx c continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendDiscordPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendEmailPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendTelegramPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendSignalPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendSlackPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendNtfyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendPushoverPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendGotifyPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendMatrixPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendGenericPruneNotification(ctx, target.EnvironmentName, result, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, result, setting.Config, s.pruneReportNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errors, setting.Provider, sendErr) s.logNotification(ctx, setting.Provider, "System Prune Report", status, errMsg, models.JSON{ "spaceReclaimed": result.SpaceReclaimed, @@ -2933,19 +2509,6 @@ func pruneResultHasChangesInternal(result *system.PruneAllResult) bool { len(result.NetworksDeleted) > 0 } -func (s *NotificationService) formatBytesInternal(bytes uint64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - func (s *NotificationService) sendDiscordPruneNotification(ctx context.Context, environmentName string, result *system.PruneAllResult, config models.JSON) error { var discordConfig models.DiscordConfig if err := s.unmarshalConfigInternal(config, &discordConfig); err != nil { @@ -2953,10 +2516,12 @@ func (s *NotificationService) sendDiscordPruneNotification(ctx context.Context, } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") } - s.decryptDiscordTokenInternal(&discordConfig) + if err := s.decryptDiscordTokenInternal(&discordConfig); err != nil { + return err + } message := notifications.BuildPruneReportNotificationMessage(notifications.MessageFormatMarkdown, environmentName, result) @@ -2974,10 +2539,12 @@ func (s *NotificationService) sendTelegramPruneNotification(ctx context.Context, } if telegramConfig.BotToken == "" || len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("telegram bot token or chat IDs not configured") + return errors.New("telegram bot token or chat IDs not configured") } - s.decryptTelegramTokenInternal(&telegramConfig) + if err := s.decryptTelegramTokenInternal(&telegramConfig); err != nil { + return err + } message := notifications.BuildPruneReportNotificationMessage(notifications.MessageFormatHTML, environmentName, result) @@ -3002,14 +2569,16 @@ func (s *NotificationService) sendEmailPruneNotification(ctx context.Context, en return err } - s.decryptEmailPasswordInternal(&emailConfig) + if err := s.decryptEmailPasswordInternal(&emailConfig); err != nil { + return err + } htmlBody, _, err := s.renderPruneReportEmailTemplate(environmentName, result) if err != nil { return fmt.Errorf("failed to render email template: %w", err) } - subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("System Prune Report: %s Reclaimed", s.formatBytesInternal(result.SpaceReclaimed))) + subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("System Prune Report: %s Reclaimed", notifications.FormatBytes(result.SpaceReclaimed))) if err := notifications.SendEmail(ctx, emailConfig, subject, htmlBody); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -3024,11 +2593,11 @@ func (s *NotificationService) renderPruneReportEmailTemplate(environmentName str "LogoURL": logoURL, "AppURL": appURL, "Environment": environmentName, - "TotalSpaceReclaimed": s.formatBytesInternal(result.SpaceReclaimed), - "ContainerSpaceReclaimed": s.formatBytesInternal(result.ContainerSpaceReclaimed), - "ImageSpaceReclaimed": s.formatBytesInternal(result.ImageSpaceReclaimed), - "VolumeSpaceReclaimed": s.formatBytesInternal(result.VolumeSpaceReclaimed), - "BuildCacheSpaceReclaimed": s.formatBytesInternal(result.BuildCacheSpaceReclaimed), + "TotalSpaceReclaimed": notifications.FormatBytes(result.SpaceReclaimed), + "ContainerSpaceReclaimed": notifications.FormatBytes(result.ContainerSpaceReclaimed), + "ImageSpaceReclaimed": notifications.FormatBytes(result.ImageSpaceReclaimed), + "VolumeSpaceReclaimed": notifications.FormatBytes(result.VolumeSpaceReclaimed), + "BuildCacheSpaceReclaimed": notifications.FormatBytes(result.BuildCacheSpaceReclaimed), "Time": time.Now().Format(time.RFC1123), } @@ -3089,11 +2658,11 @@ func (s *NotificationService) sendGotifyPruneNotification(ctx context.Context, e return err } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } message := notifications.BuildPruneReportNotificationMessage(notifications.MessageFormatPlain, environmentName, result) @@ -3163,41 +2732,13 @@ func (s *NotificationService) sendAutoHealNotificationForTargetInternal(ctx cont continue } - var sendErr error - switch setting.Provider { - case models.NotificationProviderDiscord: - sendErr = s.sendDiscordAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderEmail: - sendErr = s.sendEmailAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderTelegram: - sendErr = s.sendTelegramAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderSignal: - sendErr = s.sendSignalAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderSlack: - sendErr = s.sendSlackAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderNtfy: - sendErr = s.sendNtfyAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderPushover: - sendErr = s.sendPushoverAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderGotify: - sendErr = s.sendGotifyAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderMatrix: - sendErr = s.sendMatrixAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - case models.NotificationProviderGeneric: - sendErr = s.sendGenericAutoHealNotification(ctx, target.EnvironmentName, containerName, setting.Config) - default: + handled, sendErr := sendProviderNotificationInternal(ctx, setting.Provider, target.EnvironmentName, containerName, setting.Config, s.autoHealNotificationSendersInternal()) + if !handled { slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) continue } - status := "success" - var errMsg *string - if sendErr != nil { - status = "failed" - msg := sendErr.Error() - errMsg = new(msg) - errs = append(errs, fmt.Sprintf("%s: %s", setting.Provider, msg)) - } + status, errMsg := collectNotificationSendResultInternal(&errs, setting.Provider, sendErr) s.logNotification(ctx, setting.Provider, containerName, status, errMsg, models.JSON{ "containerID": containerID, @@ -3218,9 +2759,11 @@ func (s *NotificationService) sendDiscordAutoHealNotification(ctx context.Contex return err } if discordConfig.WebhookID == "" || discordConfig.Token == "" { - return fmt.Errorf("discord webhook ID or token not configured") + return errors.New("discord webhook ID or token not configured") + } + if err := s.decryptDiscordTokenInternal(&discordConfig); err != nil { + return err } - s.decryptDiscordTokenInternal(&discordConfig) message := notifications.BuildAutoHealNotificationMessage(notifications.MessageFormatMarkdown, environmentName, containerName) return notifications.SendDiscord(ctx, discordConfig, message) } @@ -3233,7 +2776,9 @@ func (s *NotificationService) sendEmailAutoHealNotification(ctx context.Context, if err := s.validateEmailConfigInternal(&emailConfig); err != nil { return err } - s.decryptEmailPasswordInternal(&emailConfig) + if err := s.decryptEmailPasswordInternal(&emailConfig); err != nil { + return err + } subject := notifications.BuildEmailSubject(environmentName, fmt.Sprintf("Auto Heal: Container '%s' Restarted", containerName)) body := fmt.Sprintf( "

Environment: %s

Container: %s

Automatically restarted because it was unhealthy.

", @@ -3249,9 +2794,11 @@ func (s *NotificationService) sendTelegramAutoHealNotification(ctx context.Conte return err } if telegramConfig.BotToken == "" || len(telegramConfig.ChatIDs) == 0 { - return fmt.Errorf("telegram bot token or chat IDs not configured") + return errors.New("telegram bot token or chat IDs not configured") + } + if err := s.decryptTelegramTokenInternal(&telegramConfig); err != nil { + return err } - s.decryptTelegramTokenInternal(&telegramConfig) if telegramConfig.ParseMode == "" { telegramConfig.ParseMode = "HTML" } @@ -3304,11 +2851,11 @@ func (s *NotificationService) sendGotifyAutoHealNotification(ctx context.Context return err } if gotifyConfig.Token != "" { - if decrypted, err := crypto.Decrypt(gotifyConfig.Token); err == nil { - gotifyConfig.Token = decrypted - } else { - slog.Warn("Failed to decrypt Gotify token, using raw value (may be unencrypted legacy value)", "error", err) + decrypted, err := crypto.Decrypt(gotifyConfig.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + gotifyConfig.Token = decrypted } if gotifyConfig.Title == "" { gotifyConfig.Title = notifications.BuildEmailSubject(environmentName, "Auto Heal") @@ -3348,38 +2895,145 @@ func (s *NotificationService) unmarshalConfigInternal(config models.JSON, dest a return nil } +func decodeNotificationConfigInternal[T any](config models.JSON, providerName string) (T, error) { + var out T + configBytes, err := json.Marshal(config) + if err != nil { + return out, fmt.Errorf("failed to marshal %s config: %w", providerName, err) + } + if err := json.Unmarshal(configBytes, &out); err != nil { + return out, fmt.Errorf("failed to unmarshal %s config: %w", providerName, err) + } + return out, nil +} + func (s *NotificationService) validateEmailConfigInternal(config *models.EmailConfig) error { if config.SMTPHost == "" || config.SMTPPort == 0 { - return fmt.Errorf("SMTP host or port not configured") + return errors.New("SMTP host or port not configured") } if len(config.ToAddresses) == 0 { - return fmt.Errorf("no recipient email addresses configured") + return errors.New("no recipient email addresses configured") + } + return nil +} + +func decryptStringCredentialInternal(value *string) error { + if *value == "" { + return nil + } + + decrypted, err := crypto.Decrypt(*value) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + *value = decrypted return nil } -func (s *NotificationService) decryptDiscordTokenInternal(config *models.DiscordConfig) { +func prepareSlackConfigInternal(config models.JSON, providerName string, requireToken bool) (models.SlackConfig, error) { + slackConfig, err := decodeNotificationConfigInternal[models.SlackConfig](config, providerName) + if err != nil { + return models.SlackConfig{}, err + } + if requireToken && slackConfig.Token == "" { + return models.SlackConfig{}, errors.New("slack token not configured") + } + if err := decryptStringCredentialInternal(&slackConfig.Token); err != nil { + return models.SlackConfig{}, err + } + return slackConfig, nil +} + +func prepareNtfyConfigInternal(config models.JSON, providerName string, requireTopic bool) (models.NtfyConfig, error) { + ntfyConfig, err := decodeNotificationConfigInternal[models.NtfyConfig](config, providerName) + if err != nil { + return models.NtfyConfig{}, err + } + if requireTopic && ntfyConfig.Topic == "" { + return models.NtfyConfig{}, errors.New("ntfy topic is required") + } + if err := decryptStringCredentialInternal(&ntfyConfig.Password); err != nil { + return models.NtfyConfig{}, err + } + return ntfyConfig, nil +} + +func preparePushoverConfigInternal(config models.JSON, providerName string) (models.PushoverConfig, error) { + pushoverConfig, err := decodeNotificationConfigInternal[models.PushoverConfig](config, providerName) + if err != nil { + return models.PushoverConfig{}, err + } + if err := decryptStringCredentialInternal(&pushoverConfig.Token); err != nil { + return models.PushoverConfig{}, err + } + return pushoverConfig, nil +} + +func prepareGotifyConfigInternal(config models.JSON, providerName string) (models.GotifyConfig, error) { + gotifyConfig, err := decodeNotificationConfigInternal[models.GotifyConfig](config, providerName) + if err != nil { + return models.GotifyConfig{}, err + } + if err := decryptStringCredentialInternal(&gotifyConfig.Token); err != nil { + return models.GotifyConfig{}, err + } + return gotifyConfig, nil +} + +func prepareMatrixConfigInternal(config models.JSON) (models.MatrixConfig, error) { + matrixConfig, err := decodeNotificationConfigInternal[models.MatrixConfig](config, "Matrix") + if err != nil { + return models.MatrixConfig{}, err + } + if err := decryptStringCredentialInternal(&matrixConfig.Password); err != nil { + return models.MatrixConfig{}, err + } + return matrixConfig, nil +} + +func buildVulnerabilitySummaryMessageInternal(format notifications.MessageFormat, environmentName string, payload VulnerabilityNotificationPayload) string { + return notifications.BuildVulnerabilitySummaryNotificationMessage( + format, + environmentName, + payload.CVEID, + payload.ImageName, + payload.FixedVersion, + payload.Severity, + payload.PkgName, + ) +} + +func (s *NotificationService) decryptDiscordTokenInternal(config *models.DiscordConfig) error { if config.Token != "" { - if decrypted, err := crypto.Decrypt(config.Token); err == nil { - config.Token = decrypted + decrypted, err := crypto.Decrypt(config.Token) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + config.Token = decrypted } + return nil } -func (s *NotificationService) decryptTelegramTokenInternal(config *models.TelegramConfig) { +func (s *NotificationService) decryptTelegramTokenInternal(config *models.TelegramConfig) error { if config.BotToken != "" { - if decrypted, err := crypto.Decrypt(config.BotToken); err == nil { - config.BotToken = decrypted + decrypted, err := crypto.Decrypt(config.BotToken) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + config.BotToken = decrypted } + return nil } -func (s *NotificationService) decryptEmailPasswordInternal(config *models.EmailConfig) { +func (s *NotificationService) decryptEmailPasswordInternal(config *models.EmailConfig) error { if config.SMTPPassword != "" { - if decrypted, err := crypto.Decrypt(config.SMTPPassword); err == nil { - config.SMTPPassword = decrypted + decrypted, err := crypto.Decrypt(config.SMTPPassword) + if err != nil { + return fmt.Errorf("failed to decrypt notification credential: %w", err) } + config.SMTPPassword = decrypted } + return nil } func (s *NotificationService) renderTemplatesInternal(name string, data any) (string, string, error) { diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index ed674855b6..203c7bfb12 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -31,7 +31,7 @@ func setupNotificationTestDB(t *testing.T) *database.DB { t.Helper() db, err := gorm.Open(glsqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{}, &models.AppriseSettings{})) + require.NoError(t, db.AutoMigrate(&models.NotificationSettings{}, &models.SettingVariable{}, &models.NotificationLog{}, &models.Environment{})) // Initialize crypto for tests (requires 32+ byte key) testCfg := &config.Config{ @@ -84,263 +84,6 @@ func captureNotificationServiceLogsInternal(t *testing.T) *bytes.Buffer { return &buf } -func TestNotificationService_MigrateDiscordWebhookUrlToFields(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create legacy Discord config with webhookUrl - legacyConfig := map[string]any{ - "webhookUrl": "https://discord.com/api/webhooks/123456789/abcdef123456", - "username": "Arcane Bot", - "avatarUrl": "https://example.com/avatar.png", - "events": map[string]bool{ - "image_update": true, - "container_update": false, - }, - } - - configBytes, err := json.Marshal(legacyConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Run migration - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify migration results - var migratedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&migratedSetting).Error) - - var discordConfig models.DiscordConfig - configBytes, err = json.Marshal(migratedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &discordConfig)) - - // Verify webhookId and token were extracted - require.Equal(t, "123456789", discordConfig.WebhookID) - require.NotEmpty(t, discordConfig.Token) - - // Verify token is encrypted and can be decrypted - decryptedToken, err := crypto.Decrypt(discordConfig.Token) - require.NoError(t, err) - require.Equal(t, "abcdef123456", decryptedToken) - - // Verify other fields were preserved - require.Equal(t, "Arcane Bot", discordConfig.Username) - require.Equal(t, "https://example.com/avatar.png", discordConfig.AvatarURL) - require.True(t, discordConfig.Events[models.NotificationEventImageUpdate]) - require.False(t, discordConfig.Events[models.NotificationEventContainerUpdate]) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_SkipsIfAlreadyMigrated(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create already-migrated config with webhookId and token - encryptedToken, err := crypto.Encrypt("already-migrated-token") - require.NoError(t, err) - - migratedConfig := models.DiscordConfig{ - WebhookID: "999999999", - Token: encryptedToken, - Username: "Already Migrated", - } - - configBytes, err := json.Marshal(migratedConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Run migration - should skip - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify config was NOT changed - var unchangedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&unchangedSetting).Error) - - var discordConfig models.DiscordConfig - configBytes, err = json.Marshal(unchangedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &discordConfig)) - - require.Equal(t, "999999999", discordConfig.WebhookID) - require.Equal(t, encryptedToken, discordConfig.Token) - require.Equal(t, "Already Migrated", discordConfig.Username) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_NoDiscordConfig(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // No Discord config exists - migration should not error - err := svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify no settings were created - var count int64 - require.NoError(t, db.Model(&models.NotificationSettings{}).Count(&count).Error) - require.Equal(t, int64(0), count) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_InvalidWebhookUrl(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - testCases := []struct { - name string - webhookUrl string - }{ - { - name: "missing webhooks path", - webhookUrl: "https://discord.com/api/other/123456789/abcdef", - }, - { - name: "incomplete webhook path", - webhookUrl: "https://discord.com/api/webhooks/123456789", - }, - { - name: "empty webhook url", - webhookUrl: "", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Clean up before each sub-test - db.Exec("DELETE FROM notification_settings") - - legacyConfig := map[string]any{ - "webhookUrl": tc.webhookUrl, - } - - configBytes, err := json.Marshal(legacyConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Migration should not error but should skip invalid URLs - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify config was not changed - var unchangedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&unchangedSetting).Error) - - var resultConfig map[string]any - configBytes, err = json.Marshal(unchangedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &resultConfig)) - - // Should still have webhookUrl (not migrated) - if tc.webhookUrl != "" { - require.Equal(t, tc.webhookUrl, resultConfig["webhookUrl"]) - } - }) - } -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_EmptyConfig(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create Discord setting with empty config - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: false, - Config: models.JSON{}, - } - require.NoError(t, db.Create(&setting).Error) - - // Migration should not error - err := svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify config remains empty - var unchangedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&unchangedSetting).Error) - require.Empty(t, unchangedSetting.Config) -} - -func TestNotificationService_MigrateDiscordWebhookUrlToFields_PreservesAllFields(t *testing.T) { - ctx := context.Background() - db, _, svc := setupNotificationTestServiceInternal(t) - - // Create legacy config with all optional fields - legacyConfig := map[string]any{ - "webhookUrl": "https://discord.com/api/webhooks/111222333/token444555", - "username": "Custom Bot Name", - "avatarUrl": "https://cdn.example.com/bot-avatar.jpg", - "events": map[string]bool{ - "image_update": false, - "container_update": true, - }, - } - - configBytes, err := json.Marshal(legacyConfig) - require.NoError(t, err) - - var configJSON models.JSON - require.NoError(t, json.Unmarshal(configBytes, &configJSON)) - - setting := models.NotificationSettings{ - Provider: models.NotificationProviderDiscord, - Enabled: true, - Config: configJSON, - } - require.NoError(t, db.Create(&setting).Error) - - // Run migration - err = svc.MigrateDiscordWebhookUrlToFields(ctx) - require.NoError(t, err) - - // Verify all fields were preserved - var migratedSetting models.NotificationSettings - require.NoError(t, db.Where("provider = ?", models.NotificationProviderDiscord).First(&migratedSetting).Error) - - var discordConfig models.DiscordConfig - configBytes, err = json.Marshal(migratedSetting.Config) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(configBytes, &discordConfig)) - - require.Equal(t, "111222333", discordConfig.WebhookID) - require.NotEmpty(t, discordConfig.Token) - - decryptedToken, err := crypto.Decrypt(discordConfig.Token) - require.NoError(t, err) - require.Equal(t, "token444555", decryptedToken) - - require.Equal(t, "Custom Bot Name", discordConfig.Username) - require.Equal(t, "https://cdn.example.com/bot-avatar.jpg", discordConfig.AvatarURL) - require.False(t, discordConfig.Events[models.NotificationEventImageUpdate]) - require.True(t, discordConfig.Events[models.NotificationEventContainerUpdate]) -} - func TestNotificationService_ResolveNotificationTargetInternal_UsesEnvironmentRecordAndFallback(t *testing.T) { ctx := context.Background() db, _, svc := setupNotificationTestServiceInternal(t) diff --git a/backend/internal/services/oidc_service.go b/backend/internal/services/oidc_service.go index e845353664..d77092784a 100644 --- a/backend/internal/services/oidc_service.go +++ b/backend/internal/services/oidc_service.go @@ -20,6 +20,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/sync/singleflight" + "github.com/getarcaneapp/arcane/backend/internal/common" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/pkg/utils" @@ -206,10 +207,8 @@ func (s *OidcService) ValidateMobileRedirectURI(ctx context.Context, uri string) if uri == "" { return errors.New("mobile redirect URI is empty") } - for _, allowed := range s.GetMobileRedirectAllowlist(ctx) { - if allowed == uri { - return nil - } + if slices.Contains(s.GetMobileRedirectAllowlist(ctx), uri) { + return nil } return fmt.Errorf("mobile redirect URI %q is not in the configured allowlist", uri) } @@ -328,7 +327,11 @@ func (s *OidcService) getOrDiscoverProviderInternal(ctx context.Context, cfg *mo return nil, err } - return v.(*oidc.Provider), nil + provider, ok := v.(*oidc.Provider) + if !ok { + return nil, &common.OidcProviderCacheTypeError{} + } + return provider, nil } func (s *OidcService) discoverProviderInternal(ctx context.Context, issuer string) (*oidc.Provider, string, error) { @@ -450,7 +453,7 @@ func (s *OidcService) fetchUserInfoClaimsInternal(ctx context.Context, cfg *mode request.Header.Set("Accept", "application/json") client := s.getHttpClientInternal(cfg.SkipTlsVerify) - resp, err := client.Do(request) //nolint:gosec // intentional request to configured OIDC userinfo endpoint + resp, err := client.Do(request) if err != nil { return nil, err } @@ -749,7 +752,7 @@ func (s *OidcService) makeDeviceAuthRequestInternal(ctx context.Context, endpoin req.Header.Set("Accept", "application/json") client := s.getHttpClientInternal(skipTls) - resp, err := client.Do(req) //nolint:gosec // intentional request to configured OIDC device authorization endpoint + resp, err := client.Do(req) if err != nil { slog.Error("makeDeviceAuthRequestInternal: request failed", "error", err) return nil, fmt.Errorf("device authorization request failed: %w", err) @@ -858,7 +861,7 @@ func (s *OidcService) makeTokenRequestInternal(ctx context.Context, endpoint str req.Header.Set("Accept", "application/json") client := s.getHttpClientInternal(skipTls) - resp, err := client.Do(req) //nolint:gosec // intentional request to configured OIDC token endpoint + resp, err := client.Do(req) if err != nil { slog.Error("makeTokenRequestInternal: request failed", "error", err) return nil, fmt.Errorf("token request failed: %w", err) diff --git a/backend/internal/services/playwright_service.go b/backend/internal/services/playwright_service.go index 64bac28b43..4a4f13b188 100644 --- a/backend/internal/services/playwright_service.go +++ b/backend/internal/services/playwright_service.go @@ -6,20 +6,27 @@ import ( "context" "fmt" "log/slog" + "strings" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/types/apikey" + "github.com/google/uuid" + "gorm.io/gorm" ) type PlaywrightService struct { - apiKeyService *ApiKeyService - userService *UserService + apiKeyService *ApiKeyService + userService *UserService + federatedCredentialService *FederatedCredentialService } -func NewPlaywrightService(apiKeyService *ApiKeyService, userService *UserService) *PlaywrightService { +func NewPlaywrightService(apiKeyService *ApiKeyService, userService *UserService, federatedCredentialService *FederatedCredentialService) *PlaywrightService { return &PlaywrightService{ - apiKeyService: apiKeyService, - userService: userService, + apiKeyService: apiKeyService, + userService: userService, + federatedCredentialService: federatedCredentialService, } } @@ -32,11 +39,23 @@ func (ps *PlaywrightService) CreateTestApiKeys(ctx context.Context, count int) ( return nil, fmt.Errorf("failed to get arcane user: %w", err) } + // Grant every recognized permission globally so the test key behaves like + // the legacy "admin-everywhere" credential the e2e suite expects. The + // owner is the `arcane` bootstrap user, who holds global Admin and + // therefore satisfies validateGrantsAgainstUserInternal. + allPerms := authz.AllPermissions() + grants := make([]apikey.PermissionGrant, len(allPerms)) + for i, p := range allPerms { + grants[i] = apikey.PermissionGrant{Permission: p} + } + var createdKeys []*apikey.ApiKeyCreatedDto for i := 0; i < count; i++ { + description := fmt.Sprintf("Test API key %d for Playwright tests", i+1) req := apikey.CreateApiKey{ Name: fmt.Sprintf("test-api-key-%d", i+1), - Description: new(fmt.Sprintf("Test API key %d for Playwright tests", i+1)), + Description: &description, + Permissions: grants, } apiKey, err := ps.apiKeyService.CreateApiKey(ctx, user.ID, req) @@ -59,7 +78,7 @@ func (ps *PlaywrightService) DeleteAllTestApiKeys(ctx context.Context) error { SearchQuery: pagination.SearchQuery{ Search: "test-api-key", }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: 0, Limit: 1000, }, @@ -79,3 +98,58 @@ func (ps *PlaywrightService) DeleteAllTestApiKeys(ctx context.Context) error { slog.Info("Playwright: Test API keys deleted", "count", len(apiKeys)) return nil } + +func (ps *PlaywrightService) CreateTestFederatedCredential(ctx context.Context, issuerURL string, audiences []string, subject string, roleID string, tokenTTLSeconds int) (string, error) { + if ps.federatedCredentialService == nil || ps.federatedCredentialService.db == nil { + return "", fmt.Errorf("federated credential service is not available") + } + if strings.TrimSpace(issuerURL) == "" || strings.TrimSpace(subject) == "" || strings.TrimSpace(roleID) == "" || len(audiences) == 0 { + return "", fmt.Errorf("issuerUrl, subject, roleId, and audiences are required") + } + if tokenTTLSeconds <= 0 { + tokenTTLSeconds = 600 + } + + var credentialID string + err := ps.federatedCredentialService.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + serviceUser := models.User{ + Username: "svc_federated_e2e_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + IsServiceAccount: true, + } + if err := tx.Create(&serviceUser).Error; err != nil { + return fmt.Errorf("failed to create federated e2e user: %w", err) + } + + credential := models.FederatedCredential{ + Name: "Playwright Federated Credential", + Enabled: true, + IssuerURL: strings.TrimRight(strings.TrimSpace(issuerURL), "/"), + Audiences: models.StringSlice(audiences), + SubjectClaim: "sub", + SubjectMatch: strings.TrimSpace(subject), + MatchType: models.FederatedCredentialMatchExact, + RoleID: strings.TrimSpace(roleID), + IdentityUserID: serviceUser.ID, + TokenTTLSeconds: tokenTTLSeconds, + } + if err := tx.Create(&credential).Error; err != nil { + return fmt.Errorf("failed to create federated e2e credential: %w", err) + } + + assignment := models.UserRoleAssignment{ + UserID: serviceUser.ID, + RoleID: credential.RoleID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to create federated e2e role assignment: %w", err) + } + + credentialID = credential.ID + return nil + }) + if err != nil { + return "", err + } + return credentialID, nil +} diff --git a/backend/internal/services/port_service_test.go b/backend/internal/services/port_service_test.go index 943fab11a3..cff141b6e1 100644 --- a/backend/internal/services/port_service_test.go +++ b/backend/internal/services/port_service_test.go @@ -53,7 +53,7 @@ func TestPortService_ListPortsPaginated_FlattensPublishedAndExposedPorts(t *test })) items, page, err := svc.ListPortsPaginated(context.Background(), pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) @@ -107,7 +107,7 @@ func TestPortService_ListPortsPaginated_SortsByHostPortWithUnpublishedLast(t *te Sort: "hostPort", Order: pagination.SortAsc, }, - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) @@ -154,7 +154,7 @@ func TestPortService_ListPortsPaginated_SortsByHostPortDescWithUnpublishedLast(t Sort: "hostPort", Order: pagination.SortDesc, }, - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) @@ -201,7 +201,7 @@ func TestPortService_ListPortsPaginated_SortsByHostIPDescWithUnpublishedLast(t * Sort: "hostIp", Order: pagination.SortDesc, }, - PaginationParams: pagination.PaginationParams{Limit: 20}, + Params: pagination.Params{Limit: 20}, }) require.NoError(t, err) require.Len(t, items, 3) diff --git a/backend/internal/services/project_service.go b/backend/internal/services/project_service.go index a8cd72dc0d..91d86c38ef 100644 --- a/backend/internal/services/project_service.go +++ b/backend/internal/services/project_service.go @@ -11,6 +11,7 @@ import ( "maps" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -25,7 +26,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" - libbuild "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" + "github.com/getarcaneapp/arcane/backend/pkg/libarcane/libbuild" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/projects" @@ -41,13 +42,14 @@ import ( ) type ProjectService struct { - db *database.DB - settingsService *SettingsService - eventService *EventService - imageService *ImageService - dockerService *DockerClientService - buildService *BuildService - config *config.Config + db *database.DB + settingsService *SettingsService + eventService *EventService + imageService *ImageService + dockerService *DockerClientService + buildService *BuildService + lifecycleService *LifecycleService + config *config.Config composeNameCacheMu sync.RWMutex composeNameToProjID map[string]string @@ -61,20 +63,21 @@ type composeCacheEntry struct { project *composetypes.Project } -func NewProjectService(db *database.DB, settingsService *SettingsService, eventService *EventService, imageService *ImageService, dockerService *DockerClientService, buildService *BuildService, cfg *config.Config) *ProjectService { +func NewProjectService(db *database.DB, settingsService *SettingsService, eventService *EventService, imageService *ImageService, dockerService *DockerClientService, buildService *BuildService, lifecycleService *LifecycleService, cfg *config.Config) *ProjectService { return &ProjectService{ - db: db, - settingsService: settingsService, - eventService: eventService, - imageService: imageService, - dockerService: dockerService, - buildService: buildService, - config: cfg, - composeCache: cache.NewKeyed[string, composeCacheEntry](), + db: db, + settingsService: settingsService, + eventService: eventService, + imageService: imageService, + dockerService: dockerService, + buildService: buildService, + lifecycleService: lifecycleService, + config: cfg, + composeCache: cache.NewKeyed[string, composeCacheEntry](), } } -func (s *ProjectService) getPathMapper(ctx context.Context) (*projects.PathMapper, error) { +func (s *ProjectService) getPathMapper(ctx context.Context) *projects.PathMapper { configuredPath := s.settingsService.GetStringSetting(ctx, "projectsDirectory", "/app/data/projects") var containerDir, hostDir string @@ -118,10 +121,10 @@ func (s *ProjectService) getPathMapper(ctx context.Context) (*projects.PathMappe pm := projects.NewPathMapper(containerDirResolved, hostDirResolved) if !pm.IsNonMatchingMount() { - return nil, nil + return nil } - return pm, nil + return pm } func (s *ProjectService) getProjectsDirectoryInternal(ctx context.Context) (string, error) { @@ -201,7 +204,11 @@ const ( imagePullModeAlways ) -var composePullProjectServicesInternal = projects.ComposePull +var ( + composePullProjectServicesInternal = projects.ComposePull + composeStopProjectServicesInternal = projects.ComposeStop + composeUpProjectServicesInternal = projects.ComposeUp +) func resolveServiceImagePullMode(svc composetypes.ServiceConfig) imagePullMode { rawPolicy := strings.ToLower(strings.TrimSpace(svc.PullPolicy)) @@ -377,10 +384,10 @@ func (s *ProjectService) GetProjectFromDatabaseByID(ctx context.Context, id stri var project models.Project if err := s.db.WithContext(ctx).Where("id = ?", id).First(&project).Error; err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return nil, fmt.Errorf("request canceled or timed out") + return nil, errors.New("request canceled or timed out") } if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("project not found") + return nil, errors.New("project not found") } return nil, fmt.Errorf("failed to get project: %w", err) } @@ -389,7 +396,7 @@ func (s *ProjectService) GetProjectFromDatabaseByID(ctx context.Context, id stri func (s *ProjectService) GetProjectByComposeName(ctx context.Context, name string) (*models.Project, error) { if name == "" { - return nil, fmt.Errorf("project name is empty") + return nil, errors.New("project name is empty") } normalized := normalizeComposeProjectName(name) @@ -424,7 +431,7 @@ func (s *ProjectService) GetProjectByComposeName(ctx context.Context, name strin func (s *ProjectService) resolveProjectComposeFileInternal(ctx context.Context, proj *models.Project) (string, error) { if proj == nil { - return "", fmt.Errorf("project is nil") + return "", errors.New("project is nil") } if proj.GitOpsManagedBy != nil && strings.TrimSpace(*proj.GitOpsManagedBy) != "" { @@ -472,10 +479,7 @@ func (s *ProjectService) loadComposeProjectForProjectInternal(ctx context.Contex projectsDirectory = "/app/data/projects" } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } + pathMapper := s.getPathMapper(ctx) composeProject, loadErr := projects.LoadComposeProject(ctx, composeFileFullPath, normalizeComposeProjectName(proj.Name), projectsDirectory, utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false), pathMapper) if loadErr != nil { @@ -485,9 +489,9 @@ func (s *ProjectService) loadComposeProjectForProjectInternal(ctx context.Contex return composeProject, composeFileFullPath, nil } -func (s *ProjectService) getCachedComposeProjectInternal(ctx context.Context, proj *models.Project, cfg *models.Settings) (*composetypes.Project, string, error) { +func (s *ProjectService) getCachedComposeProjectInternal(ctx context.Context, proj *models.Project, cfg *models.Settings) (*composetypes.Project, error) { if proj == nil { - return nil, "", fmt.Errorf("project is nil") + return nil, errors.New("project is nil") } if s.composeCache == nil { s.composeCache = cache.NewKeyed[string, composeCacheEntry]() @@ -525,10 +529,10 @@ func (s *ProjectService) getCachedComposeProjectInternal(ctx context.Context, pr return entry, nil }) if err != nil { - return nil, "", err + return nil, err } - return entry.project, entry.composePath, nil + return entry.project, nil } func validComposeCacheEntryInternal(entry composeCacheEntry) bool { @@ -721,11 +725,32 @@ func (s *ProjectService) pullAndReconcileImageInternal( return nil } +type projectProgressSuppressedContextKey struct{} + +func withProjectProgressSuppressedInternal(ctx context.Context) context.Context { + return context.WithValue(ctx, projectProgressSuppressedContextKey{}, true) +} + +func writeProjectProgressInternal(ctx context.Context, message string, progress int, phase string) { + if suppressed, _ := ctx.Value(projectProgressSuppressedContextKey{}).(bool); suppressed { + return + } + progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) + if progressWriter == nil { + return + } + payload := fmt.Sprintf(`{"type":"project","phase":%q,"status":%q,"progressDetail":{"current":%d,"total":100}}`+"\n", phase, message, progress) + if _, err := progressWriter.Write([]byte(payload)); err != nil { + slog.DebugContext(ctx, "failed to write project progress", "phase", phase, "error", err) + } +} + func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID string, servicesToUpdate []string, user models.User) error { projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID) if err != nil { return err } + previousStatus := projectFromDb.Status // 1. Load project compProj, _, err := s.loadComposeProjectForProjectInternal(ctx, projectFromDb, nil) @@ -739,27 +764,33 @@ func (s *ProjectService) UpdateProjectServices(ctx context.Context, projectID st } // 3. Pull images for specific services + writeProjectProgressInternal(ctx, "Pulling updated service images", 20, "pull") if err := s.composePullSelectedServicesInternal(ctx, compProj, servicesToUpdate); err != nil { - slog.WarnContext(ctx, "compose pull failed, continuing", "error", err) + if statusErr := s.updateProjectStatusInternal(ctx, projectID, previousStatus); statusErr != nil { + slog.ErrorContext(ctx, "UpdateProjectServices: failed to restore project status after compose pull failure", "projectID", projectID, "error", statusErr) + } + return fmt.Errorf("pull updated service images: %w", err) } // 4. Stop specific services - if err := projects.ComposeStop(ctx, compProj, servicesToUpdate); err != nil { + writeProjectProgressInternal(ctx, "Stopping selected services", 45, "stop") + if err := composeStopProjectServicesInternal(ctx, compProj, servicesToUpdate); err != nil { slog.WarnContext(ctx, "compose stop failed, continuing", "error", err) } // 5. Up specific services - if err := projects.ComposeUp(ctx, compProj, servicesToUpdate, false, false); err != nil { - if statusErr := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped); statusErr != nil { - slog.ErrorContext(ctx, "UpdateProjectServices: failed to set stopped status after compose up failure", "projectID", projectID, "error", statusErr) - } + writeProjectProgressInternal(ctx, "Starting selected services", 70, "up") + if err := composeUpProjectServicesInternal(ctx, compProj, servicesToUpdate, false, true); err != nil { + s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) return fmt.Errorf("failed to up services: %w", err) } // 6. Finalize status + writeProjectProgressInternal(ctx, "Refreshing project status", 90, "status") if err := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusRunning); err != nil { return err } + writeProjectProgressInternal(ctx, "Service update completed", 100, "complete") metadata := models.JSON{ "action": "update_services", @@ -1029,7 +1060,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, err } if strings.TrimSpace(relativePath) == "" { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("relative path is required")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("relative path is required")} } composeFile, detectErr := s.resolveProjectComposeFileInternal(ctx, proj) @@ -1046,7 +1077,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r continue } if !projects.IsSafeSubdirectory(proj.Path, inc.Path) { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } return readProjectIncludeFileContentInternal(proj.Path, inc) } @@ -1063,7 +1094,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, fmt.Errorf("failed to resolve file path: %w", err) } if !projects.IsSafeSubdirectory(absProjectPath, absFilePath) { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } info, err := os.Lstat(absFilePath) @@ -1074,10 +1105,10 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, fmt.Errorf("failed to stat file: %w", err) } if info.IsDir() { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("path refers to a directory")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("path refers to a directory")} } if info.Mode()&os.ModeSymlink != 0 { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("symlink files are not supported")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("symlink files are not supported")} } content, err := os.ReadFile(absFilePath) @@ -1088,7 +1119,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r return project.IncludeFile{}, fmt.Errorf("failed to read file: %w", err) } if projects.IsBinaryProjectFileContent(content) { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("binary files are not supported")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("binary files are not supported")} } return project.IncludeFile{ @@ -1101,7 +1132,7 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r func readProjectIncludeFileContentInternal(projectPath string, inc projects.IncludeFile) (project.IncludeFile, error) { validatedPath, err := projects.ValidateIncludePathForWrite(projectPath, inc.Path) if err != nil { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } resolvedProjectPath, err := filepath.EvalSymlinks(projectPath) @@ -1120,7 +1151,7 @@ func readProjectIncludeFileContentInternal(projectPath string, inc projects.Incl return project.IncludeFile{}, fmt.Errorf("failed to resolve include file: %w", err) } if !projects.IsSafeSubdirectory(resolvedProjectPath, resolvedPath) { - return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")} + return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: errors.New("file path is outside project directory")} } info, err := os.Stat(resolvedPath) @@ -1131,7 +1162,7 @@ func readProjectIncludeFileContentInternal(projectPath string, inc projects.Incl return project.IncludeFile{}, fmt.Errorf("failed to stat include file: %w", err) } if info.IsDir() { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("path refers to a directory")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("path refers to a directory")} } content, err := os.ReadFile(resolvedPath) @@ -1142,7 +1173,7 @@ func readProjectIncludeFileContentInternal(projectPath string, inc projects.Incl return project.IncludeFile{}, fmt.Errorf("failed to read include file: %w", err) } if projects.IsBinaryProjectFileContent(content) { - return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("binary files are not supported")} + return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: errors.New("binary files are not supported")} } return project.IncludeFile{ @@ -1183,9 +1214,9 @@ func (s *ProjectService) enrichProjectUpdateInfoInternal(ctx context.Context, re return } - imageRefs := buildProjectImageRefsFromComposeConfigsInternal(resp.Services) + imageRefs := projects.ImageRefsFromComposeConfigs(resp.Services) if len(imageRefs) == 0 { - imageRefs = buildProjectImageRefsFromRuntimeServicesInternal(resp.RuntimeServices) + imageRefs = projects.ImageRefsFromRuntimeServices(resp.RuntimeServices) } var updateInfoByRef map[string]*imagetypes.UpdateInfo @@ -1299,59 +1330,12 @@ func (s *ProjectService) enrichProjectsWithUpdateInfoInternal( } func (s *ProjectService) getProjectImageRefsFromComposeInternal(ctx context.Context, proj models.Project, cfg *models.Settings) ([]string, error) { - composeProject, _, err := s.getCachedComposeProjectInternal(ctx, &proj, cfg) + composeProject, err := s.getCachedComposeProjectInternal(ctx, &proj, cfg) if err != nil { return nil, fmt.Errorf("load compose project: %w", err) } - return buildProjectImageRefsFromComposeServicesInternal(composeProject.Services), nil -} - -func buildProjectImageRefsFromComposeServicesInternal(services composetypes.Services) []string { - serviceConfigs := make([]composetypes.ServiceConfig, 0, len(services)) - for _, svc := range services { - serviceConfigs = append(serviceConfigs, svc) - } - - return buildProjectImageRefsFromComposeConfigsInternal(serviceConfigs) -} - -func buildProjectImageRefsFromComposeConfigsInternal(services []composetypes.ServiceConfig) []string { - refs := make([]string, 0, len(services)) - seen := make(map[string]struct{}, len(services)) - - for _, svc := range services { - ref := strings.TrimSpace(svc.Image) - if ref == "" { - continue - } - if _, exists := seen[ref]; exists { - continue - } - seen[ref] = struct{}{} - refs = append(refs, ref) - } - - return refs -} - -func buildProjectImageRefsFromRuntimeServicesInternal(services []project.RuntimeService) []string { - refs := make([]string, 0, len(services)) - seen := make(map[string]struct{}, len(services)) - - for _, svc := range services { - ref := strings.TrimSpace(svc.Image) - if ref == "" { - continue - } - if _, exists := seen[ref]; exists { - continue - } - seen[ref] = struct{}{} - refs = append(refs, ref) - } - - return refs + return projects.ImageRefsFromComposeServices(composeProject.Services), nil } func buildProjectUpdateInfoSummaryInternal( @@ -1387,13 +1371,11 @@ func buildProjectUpdateInfoSummaryInternal( if strings.TrimSpace(info.Error) != "" { summary.ErrorCount++ if summary.ErrorMessage == nil { - errMsg := strings.TrimSpace(info.Error) - summary.ErrorMessage = &errMsg + summary.ErrorMessage = new(strings.TrimSpace(info.Error)) } } if !info.CheckTime.IsZero() && (latestCheckTime == nil || info.CheckTime.After(*latestCheckTime)) { - checkTime := info.CheckTime - latestCheckTime = &checkTime + latestCheckTime = new(info.CheckTime) } } @@ -1453,7 +1435,7 @@ func (s *ProjectService) enrichWithGitOpsInfo(ctx context.Context, proj *models. } func (s *ProjectService) enrichWithComposeServiceConfigs(ctx context.Context, proj *models.Project, composeFile string, resp *project.Details) { - composeProj, _, loadErr := s.getCachedComposeProjectInternal(ctx, proj, nil) + composeProj, loadErr := s.getCachedComposeProjectInternal(ctx, proj, nil) if loadErr != nil { slog.WarnContext(ctx, "failed to load compose service configs", "path", composeFile, "error", loadErr) return @@ -1728,7 +1710,7 @@ func (s *ProjectService) GetProjectStatusCounts(ctx context.Context) (folderCoun // 2. Group by project containersByProject := make(map[string][]container.Summary) for _, c := range containers { - projName := c.Labels["com.docker.compose.project"] + projName := dockerutil.ComposeProjectLabel(c.Labels) if projName != "" { containersByProject[projName] = append(containersByProject[projName], c) } @@ -1865,15 +1847,27 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us resolvedPullPolicy = "missing" } + if err := s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusDeploying); err != nil { + return fmt.Errorf("failed to update project status to deploying: %w", err) + } + + // Run any configured pre-deploy lifecycle hook before loading the compose + // project so hooks can produce files that compose then consumes (e.g. + // `sops -d secrets.enc.env > .env.runtime` for an `env_file: .env.runtime` + // service). A failed hook aborts the deploy. + if s.lifecycleService != nil { + if lerr := s.lifecycleService.RunPreDeploy(ctx, projectFromDb); lerr != nil { + s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) + return fmt.Errorf("pre-deploy lifecycle hook failed: %w", lerr) + } + } + project, _, derr := s.loadComposeProjectForProjectInternal(ctx, projectFromDb, nil) if derr != nil { + s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) return fmt.Errorf("failed to load compose project in %s: %w", projectFromDb.Path, derr) } - if err := s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusDeploying); err != nil { - return fmt.Errorf("failed to update project status to deploying: %w", err) - } - progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) if perr := s.prepareProjectImagesForDeploy(ctx, projectID, project, progressWriter, nil, &user, resolvedPullPolicy); perr != nil { s.restoreProjectStatusAfterFailedDeployInternal(ctx, projectID) @@ -1931,6 +1925,7 @@ func (s *ProjectService) DownProject(ctx context.Context, projectID string, user return fmt.Errorf("failed to update project status to stopping: %w", err) } + writeProjectProgressInternal(ctx, "Stopping project services", 45, "down") if err := projects.ComposeDown(ctx, proj, false); err != nil { _ = s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusRunning) return fmt.Errorf("failed to bring down project: %w", err) @@ -1945,7 +1940,12 @@ func (s *ProjectService) DownProject(ctx context.Context, projectID string, user slog.ErrorContext(ctx, "could not log project down action", "error", logErr) } - return s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped) + writeProjectProgressInternal(ctx, "Refreshing project status", 90, "status") + if err := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusStopped); err != nil { + return err + } + writeProjectProgressInternal(ctx, "Project stopped", 100, "complete") + return nil } func (s *ProjectService) CreateProject(ctx context.Context, name, composeContent string, envContent *string, user models.User) (*models.Project, error) { @@ -2013,11 +2013,13 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r "projectName", proj.Name, "projectPath", proj.Path) - if err := s.DownProject(ctx, projectID, systemUser); err != nil { + writeProjectProgressInternal(ctx, "Stopping project before destroy", 25, "down") + if err := s.DownProject(withProjectProgressSuppressedInternal(ctx), projectID, systemUser); err != nil { slog.WarnContext(ctx, "failed to bring down project", "error", err) } if removeVolumes { + writeProjectProgressInternal(ctx, "Removing project volumes", 55, "volumes") if compProj, _, lerr := s.loadComposeProjectForProjectInternal(ctx, proj, nil); lerr == nil { if derr := projects.ComposeDown(ctx, compProj, true); derr != nil { slog.WarnContext(ctx, "failed to remove volumes", "error", derr) @@ -2028,6 +2030,7 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r } if removeFiles { + writeProjectProgressInternal(ctx, "Removing project files", 75, "files") slog.DebugContext(ctx, "Removing project files", "path", proj.Path) if err := os.RemoveAll(proj.Path); err != nil { slog.ErrorContext(ctx, "Failed to remove project files", "path", proj.Path, "error", err) @@ -2042,6 +2045,7 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r return fmt.Errorf("failed to delete project from database: %w", err) } s.invalidateComposeCacheInternal(projectID) + writeProjectProgressInternal(ctx, "Project destroyed", 100, "complete") metadata := models.JSON{"action": "destroy", "projectID": projectID, "projectName": proj.Name, "removeFiles": removeFiles, "removeVolumes": removeVolumes} if logErr := s.eventService.LogProjectEvent(ctx, models.EventTypeProjectDelete, projectID, proj.Name, user.ID, user.Username, "0", metadata); logErr != nil { @@ -2060,15 +2064,20 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, return err } - disabled, err := s.projectRedeployDisabledInternal(ctx, *proj) - if err != nil { - return err - } + disabled := s.projectRedeployDisabledInternal(ctx, *proj) if disabled { return &common.ArcaneSelfRedeployError{} } - if err := s.PullProjectImages(ctx, projectID, io.Discard, user, nil); err != nil { + progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer) + if progressWriter == nil { + progressWriter = io.Discard + } + if _, writeErr := progressWriter.Write([]byte(`{"type":"deploy","phase":"pull","status":"pulling project images"}` + "\n")); writeErr != nil { + slog.DebugContext(ctx, "failed to write redeploy pull progress", "error", writeErr) + } + + if err := s.PullProjectImages(ctx, projectID, progressWriter, user, nil); err != nil { slog.WarnContext(ctx, "failed to pull project images", "error", err) } @@ -2077,19 +2086,23 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, slog.ErrorContext(ctx, "could not log project redeploy action", "error", logErr) } + if _, writeErr := progressWriter.Write([]byte(`{"type":"deploy","phase":"up","status":"starting project deployment"}` + "\n")); writeErr != nil { + slog.DebugContext(ctx, "failed to write redeploy deploy progress", "error", writeErr) + } + return s.DeployProject(ctx, projectID, user, nil) } -func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, proj models.Project) (bool, error) { +func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, proj models.Project) bool { containers, err := projects.ListGlobalComposeContainers(ctx) if err != nil { slog.WarnContext(ctx, "could not list compose containers to check self-redeploy guard; skipping guard", "error", err) - return false, nil + return false } containersByProject := make(map[string][]container.Summary) for _, c := range containers { - projectName := c.Labels["com.docker.compose.project"] + projectName := dockerutil.ComposeProjectLabel(c.Labels) if projectName != "" { containersByProject[projectName] = append(containersByProject[projectName], c) } @@ -2098,11 +2111,11 @@ func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, pr currentContainerID, currentContainerErr := dockerutil.GetCurrentContainerID() for _, container := range lookupProjectContainers(proj, containersByProject) { if libupdater.ShouldDisableArcaneServerRedeploy(container.Labels, container.ID, currentContainerID, currentContainerErr) { - return true, nil + return true } } - return false, nil + return false } func (s *ProjectService) PullProjectImages(ctx context.Context, projectID string, progressWriter io.Writer, user models.User, credentials []containerregistry.Credential) error { @@ -2212,10 +2225,6 @@ func (s *ProjectService) ensureImagesPresent(ctx context.Context, pullPlan map[s return nil } -func (s *ProjectService) pullImageForService(ctx context.Context, imageRef string, progressWriter io.Writer, credentials []containerregistry.Credential) error { - return s.pullAndReconcileImageInternal(ctx, imageRef, progressWriter, systemUser, credentials) -} - func (s *ProjectService) prepareProjectImagesForDeploy( ctx context.Context, projectID string, @@ -2229,11 +2238,6 @@ func (s *ProjectService) prepareProjectImagesForDeploy( return nil } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } - for name, svc := range project.Services { svc, imageName, updated := prepareDeployServiceConfig(projectID, project.Name, name, svc) if updated { @@ -2248,7 +2252,7 @@ func (s *ProjectService) prepareProjectImagesForDeploy( if updated { decision = deployImageDecision{Build: true} } - if err := s.ensureDeployServiceImageReady(ctx, projectID, project, name, svc, imageName, decision, progressWriter, credentials, user, pathMapper); err != nil { + if err := s.ensureDeployServiceImageReady(ctx, projectID, project, name, svc, imageName, decision, progressWriter, credentials, user); err != nil { return err } } @@ -2280,10 +2284,9 @@ func (s *ProjectService) ensureDeployServiceImageReady( progressWriter io.Writer, credentials []containerregistry.Credential, user *models.User, - pathMapper *projects.PathMapper, ) error { if decision.Build { - return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user, pathMapper) + return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user) } exists, err := s.imageService.ImageExistsLocally(ctx, imageName) @@ -2302,14 +2305,15 @@ func (s *ProjectService) ensureDeployServiceImageReady( return nil } - if err := s.pullImageForService(ctx, imageName, progressWriter, credentials); err == nil { + err = s.pullAndReconcileImageInternal(ctx, imageName, progressWriter, systemUser, credentials) + if err == nil { return nil - } else if svc.Build != nil && decision.FallbackBuildOnPullFail { + } + if svc.Build != nil && decision.FallbackBuildOnPullFail { slog.WarnContext(ctx, "image pull failed, falling back to build", "service", serviceName, "image", imageName, "error", err) - return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user, pathMapper) - } else { - return fmt.Errorf("failed to pull image %s: %w", imageName, err) + return s.buildServiceImageForDeploy(ctx, projectID, project, serviceName, svc, progressWriter, user) } + return fmt.Errorf("failed to pull image %s: %w", imageName, err) } func (s *ProjectService) buildServiceImageForDeploy( @@ -2320,13 +2324,12 @@ func (s *ProjectService) buildServiceImageForDeploy( svc composetypes.ServiceConfig, progressWriter io.Writer, user *models.User, - pathMapper *projects.PathMapper, ) error { if s.buildService == nil { return fmt.Errorf("build service not available for service %s", serviceName) } - buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, serviceName, svc, ProjectBuildOptions{}, pathMapper) + buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, serviceName, svc, ProjectBuildOptions{}) if err != nil { return err } @@ -2449,13 +2452,13 @@ func resolveBuildContextInternal(workingDir string, svc composetypes.ServiceConf return contextDir, nil } -func resolveDockerfilePathInternal(svc composetypes.ServiceConfig) (string, error) { +func resolveDockerfilePathInternal(svc composetypes.ServiceConfig) string { dockerfilePath := strings.TrimSpace(svc.Build.Dockerfile) if dockerfilePath == "" { dockerfilePath = "Dockerfile" } - return dockerfilePath, nil + return dockerfilePath } func buildArgsFromCompose(args map[string]*string) map[string]string { @@ -2510,7 +2513,7 @@ func ulimitsFromCompose(ulimits map[string]*composetypes.UlimitsConfig) map[stri switch { case cfg.Single > 0: - out[name] = fmt.Sprintf("%d", cfg.Single) + out[name] = strconv.Itoa(cfg.Single) case cfg.Soft > 0 || cfg.Hard > 0: out[name] = fmt.Sprintf("%d:%d", cfg.Soft, cfg.Hard) } @@ -2573,7 +2576,6 @@ func (s *ProjectService) prepareServiceBuildRequest( serviceName string, svc composetypes.ServiceConfig, options ProjectBuildOptions, - pathMapper *projects.PathMapper, ) (imagetypes.BuildRequest, composetypes.ServiceConfig, bool, error) { _ = ctx imageName, updatedSvc, updated := ensureServiceImage(projectID, project.Name, serviceName, svc) @@ -2593,7 +2595,7 @@ func (s *ProjectService) prepareServiceBuildRequest( // therefore stay as a container path; translating it to the host path // (which is what bind mount sources need) makes `os.Stat` fail because // the host path doesn't exist inside the Arcane container. See #2314. - // pathMapper is intentionally not consumed here for that reason. + // For that reason the build context dir is deliberately left untranslated. contextDir, err := resolveBuildContextInternal(project.WorkingDir, updatedSvc, serviceName) if err != nil { return imagetypes.BuildRequest{}, updatedSvc, updated, err @@ -2606,10 +2608,7 @@ func (s *ProjectService) prepareServiceBuildRequest( dockerfilePath := "" if strings.TrimSpace(dockerfileInline) == "" { - dockerfilePath, err = resolveDockerfilePathInternal(updatedSvc) - if err != nil { - return imagetypes.BuildRequest{}, updatedSvc, updated, err - } + dockerfilePath = resolveDockerfilePathInternal(updatedSvc) } buildReq := imagetypes.BuildRequest{ @@ -2652,16 +2651,16 @@ func (s *ProjectService) restoreProjectStatusAfterFailedDeployInternal(ctx conte if err == nil { serviceCount, runningCount := s.getServiceCounts(services) status := s.calculateProjectStatus(services) - if updateErr := s.db.WithContext(ctx).Model(&models.Project{}).Where("id = ?", projectID).Updates(map[string]any{ + updateErr := s.db.WithContext(ctx).Model(&models.Project{}).Where("id = ?", projectID).Updates(map[string]any{ "status": status, "service_count": serviceCount, "running_count": runningCount, "updated_at": time.Now(), - }).Error; updateErr == nil { + }).Error + if updateErr == nil { return - } else { - slog.WarnContext(ctx, "failed to restore project status after deploy failure", "projectID", projectID, "error", updateErr) } + slog.WarnContext(ctx, "failed to restore project status after deploy failure", "projectID", projectID, "error", updateErr) } else { slog.WarnContext(ctx, "failed to inspect project services after deploy failure", "projectID", projectID, "error", err) } @@ -2681,11 +2680,6 @@ func (s *ProjectService) buildProjectServicesInternal(ctx context.Context, proje selected := normalizeBuildSelections(options.Services) - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } - buildCount := 0 for name, svc := range project.Services { if svc.Build == nil { @@ -2695,7 +2689,7 @@ func (s *ProjectService) buildProjectServicesInternal(ctx context.Context, proje continue } - buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, name, svc, options, pathMapper) + buildReq, updatedSvc, updated, err := s.prepareServiceBuildRequest(ctx, projectID, project, name, svc, options) if err != nil { return err } @@ -2773,10 +2767,7 @@ func (s *ProjectService) RestartProject(ctx context.Context, projectID string, u projectsDirectory = "/app/data/projects" } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } + pathMapper := s.getPathMapper(ctx) compProj, _, lerr := projects.LoadComposeProjectFromDir(ctx, proj.Path, normalizeComposeProjectName(proj.Name), projectsDirectory, utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false), pathMapper) if lerr != nil { @@ -2784,6 +2775,7 @@ func (s *ProjectService) RestartProject(ctx context.Context, projectID string, u return fmt.Errorf("failed to load compose project: %w", lerr) } + writeProjectProgressInternal(ctx, "Restarting project services", 55, "restart") if err := projects.ComposeRestart(ctx, compProj, nil); err != nil { _ = s.updateProjectStatusInternal(ctx, projectID, models.ProjectStatusRunning) return fmt.Errorf("failed to restart project: %w", err) @@ -2798,7 +2790,12 @@ func (s *ProjectService) RestartProject(ctx context.Context, projectID string, u slog.ErrorContext(ctx, "could not log project restart action", "error", logErr) } - return s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusRunning) + writeProjectProgressInternal(ctx, "Refreshing project status", 90, "status") + if err := s.updateProjectStatusandCountsInternal(ctx, projectID, models.ProjectStatusRunning); err != nil { + return err + } + writeProjectProgressInternal(ctx, "Project restarted", 100, "complete") + return nil } func (s *ProjectService) UpdateProject(ctx context.Context, projectID string, name *string, composeContent, envContent *string, user models.User) (*models.Project, error) { @@ -2908,7 +2905,7 @@ func (s *ProjectService) getProjectForUpdate(ctx context.Context, projectID stri var proj models.Project if err := s.db.WithContext(ctx).First(&proj, "id = ?", projectID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return models.Project{}, "", fmt.Errorf("project not found") + return models.Project{}, "", errors.New("project not found") } return models.Project{}, "", fmt.Errorf("failed to get project: %w", err) } @@ -3164,7 +3161,7 @@ func parseComposeValidationEnvContent(content string, contextEnv projects.EnvMap return nil, fmt.Errorf("parse env: %w", err) } - return projects.EnvMap(envMap), nil + return envMap, nil } func withTransientValidationEnvFile(projectPath string, effectiveEnvContent *string, run func() error) (err error) { @@ -3411,7 +3408,7 @@ func (s *ProjectService) persistGitSyncEnvFilesInternal(projectPath, projectsDir } if update.effectiveContent == nil { - return fmt.Errorf("missing effective env content for git sync update") + return errors.New("missing effective env content for git sync update") } if err := projects.WriteEnvFile(projectsDirectory, projectPath, *update.effectiveContent); err != nil { @@ -3447,7 +3444,7 @@ func (s *ProjectService) applyProjectRenameIfNeeded(proj *models.Project, name * newDirName := projects.SanitizeProjectName(newName) if newDirName == "" || strings.Trim(newDirName, "_") == "" { - return fmt.Errorf("invalid project name: results in empty directory name") + return errors.New("invalid project name: results in empty directory name") } currentPath := filepath.Clean(proj.Path) @@ -3676,7 +3673,6 @@ func (s *ProjectService) listProjectsWithDerivedFiltersInternal( paginationResp := pagination.BuildResponseFromFilterResult(result, params) return result.Items, paginationResp, nil - } func (s *ProjectService) filterProjectsWithDerivedFiltersInternal( @@ -3785,7 +3781,7 @@ func buildDiscoveredComposeProjectUpdateRowsInternal( ) []project.Details { containersByProject := make(map[string][]container.Summary) for _, c := range composeContainers { - projectName := strings.TrimSpace(c.Labels["com.docker.compose.project"]) + projectName := dockerutil.ComposeProjectLabel(c.Labels) if projectName == "" { continue } @@ -3809,7 +3805,7 @@ func buildDiscoveredComposeProjectUpdateRowsInternal( rows := make([]project.Details, 0, len(containersByProject)) for projectName, projectContainers := range containersByProject { runtimeServices := buildDiscoveredRuntimeServicesInternal(projectContainers) - imageRefs := buildProjectImageRefsFromRuntimeServicesInternal(runtimeServices) + imageRefs := projects.ImageRefsFromRuntimeServices(runtimeServices) updateInfo := buildProjectUpdateInfoSummaryInternal(imageRefs, updateInfoByRef) if updateInfo == nil || !updateInfo.HasUpdate { continue @@ -3922,7 +3918,7 @@ func buildDiscoveredRuntimeServicesInternal(containers []container.Summary) []pr continue } - serviceName := strings.TrimSpace(c.Labels["com.docker.compose.service"]) + serviceName := dockerutil.ComposeServiceLabel(c.Labels) if serviceName == "" { serviceName = c.ID } @@ -3932,10 +3928,7 @@ func buildDiscoveredRuntimeServicesInternal(containers []container.Summary) []pr } seenServices[key] = struct{}{} - containerName := "" - if len(c.Names) > 0 { - containerName = strings.TrimPrefix(c.Names[0], "/") - } + containerName := dockerutil.ContainerNameFromNames(c.Names) runtimeServices = append(runtimeServices, project.RuntimeService{ Name: serviceName, @@ -4080,7 +4073,7 @@ func (s *ProjectService) countProjectsByUpdateStatusInternal(ctx context.Context Filters: map[string]string{ "updates": status, }, - PaginationParams: pagination.PaginationParams{ + Params: pagination.Params{ Start: 0, Limit: 0, }, @@ -4124,7 +4117,7 @@ func (s *ProjectService) fetchProjectStatusConcurrently(ctx context.Context, pro // 2. Group containers by project name containersByProject := make(map[string][]container.Summary) for _, c := range containers { - projName := c.Labels["com.docker.compose.project"] + projName := dockerutil.ComposeProjectLabel(c.Labels) if projName != "" { containersByProject[projName] = append(containersByProject[projName], c) } @@ -4157,11 +4150,11 @@ func (s *ProjectService) mapProjectToDto(ctx context.Context, projectsDir string projectContainers := lookupProjectContainers(p, containersByProject) - var services []ProjectServiceInfo + services := make([]ProjectServiceInfo, 0, len(projectContainers)) runningCount := 0 for _, c := range projectContainers { - svcName := c.Labels["com.docker.compose.service"] + svcName := dockerutil.ComposeServiceLabel(c.Labels) state := c.State // "running", "exited", etc. // Parse health from Status string if possible @@ -4176,10 +4169,7 @@ func (s *ProjectService) mapProjectToDto(ctx context.Context, projectsDir string health = new("starting") } - containerName := "" - if len(c.Names) > 0 { - containerName = strings.TrimPrefix(c.Names[0], "/") - } + containerName := dockerutil.ContainerNameFromNames(c.Names) redeployDisabled := libupdater.ShouldDisableArcaneServerRedeploy(c.Labels, c.ID, currentContainerID, currentContainerErr) if redeployDisabled { @@ -4304,10 +4294,7 @@ func (s *ProjectService) loadComposeMetadataForSyncInternal(ctx context.Context, return 0, nil, pErr } - pathMapper, pmErr := s.getPathMapper(ctx) - if pmErr != nil { - slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr) - } + pathMapper := s.getPathMapper(ctx) normName := normalizeComposeProjectName(dirName) autoInjectEnv := utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false) diff --git a/backend/internal/services/project_service_test.go b/backend/internal/services/project_service_test.go index f60620c2ff..33961802f8 100644 --- a/backend/internal/services/project_service_test.go +++ b/backend/internal/services/project_service_test.go @@ -1,6 +1,7 @@ package services import ( + "bytes" "context" "encoding/json" "errors" @@ -30,7 +31,7 @@ import ( glsqlite "github.com/glebarez/sqlite" "github.com/moby/moby/api/types/container" dockertypesimage "github.com/moby/moby/api/types/image" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -39,6 +40,17 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" ) +func TestWriteProjectProgressInternal_SuppressedContextSkipsProgress(t *testing.T) { + ctx := context.Background() + var buf bytes.Buffer + ctx = context.WithValue(ctx, projects.ProgressWriterKey{}, &buf) + ctx = withProjectProgressSuppressedInternal(ctx) + + writeProjectProgressInternal(ctx, "Project stopped", 100, "complete") + + require.Empty(t, buf.String()) +} + type testBuildBuilder struct { err error } @@ -112,7 +124,7 @@ func TestProjectService_GetProjectFromDatabaseByID(t *testing.T) { // Setup dependencies settingsService, _ := NewSettingsService(ctx, db) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) // Create test project proj := &models.Project{ @@ -229,7 +241,7 @@ func TestProjectService_CalculateProjectStatus(t *testing.T) { func TestProjectService_UpdateProjectStatusInternal(t *testing.T) { db := setupProjectTestDB(t) ctx := context.Background() - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) proj := &models.Project{ BaseModel: models.BaseModel{ @@ -341,7 +353,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("exact match", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) proj := &models.Project{ BaseModel: models.BaseModel{ID: "p1"}, @@ -357,7 +369,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("normalized fallback", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) proj := &models.Project{ BaseModel: models.BaseModel{ID: "p1"}, @@ -373,7 +385,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("display name in db, normalized compose label input", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) display := &models.Project{ BaseModel: models.BaseModel{ID: "p2"}, @@ -389,7 +401,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("invalidates stale normalized cache entries after deletion", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) original := &models.Project{ BaseModel: models.BaseModel{ID: "p3"}, @@ -426,7 +438,7 @@ func TestProjectService_GetProjectByComposeName(t *testing.T) { t.Run("invalidates stale normalized cache entries after rename", func(t *testing.T) { db := setupProjectTestDB(t) - svc := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) original := &models.Project{ BaseModel: models.BaseModel{ID: "p5"}, @@ -565,9 +577,9 @@ func TestProjectService_PullProjectImages_UpdatesCurrentImageRecordAfterPull(t * dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) projectPath := createComposeProjectDir(t, projectsDir, "compose-pull") composeContent := fmt.Sprintf("services:\n app:\n image: %s\n builder:\n build: .\n", imageRef) @@ -660,9 +672,9 @@ func TestProjectService_EnsureImagesPresent_UpdatesCurrentImageRecordAfterPull(t dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) require.NoError(t, db.Create(&models.ImageUpdateRecord{ ID: "sha256:old-api", @@ -712,9 +724,9 @@ func TestProjectService_PullImageForService_UpdatesCurrentImageRecordAfterPull(t dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) require.NoError(t, db.Create(&models.ImageUpdateRecord{ ID: "sha256:old-worker", @@ -726,7 +738,7 @@ func TestProjectService_PullImageForService_UpdatesCurrentImageRecordAfterPull(t CheckTime: time.Now().UTC().Add(-time.Hour), }).Error) - require.NoError(t, svc.pullImageForService(ctx, imageRef, io.Discard, nil)) + require.NoError(t, svc.pullAndReconcileImageInternal(ctx, imageRef, io.Discard, systemUser, nil)) // sha256:old-worker may still be in use by another container — must not be cleared (fixes #2453). var oldRecord models.ImageUpdateRecord @@ -761,9 +773,9 @@ func TestProjectService_ComposePullSelectedServicesInternal_ReconcilesOnlyOnSucc dockerService := &DockerClientService{client: newTestDockerClient(t, server)} eventService := NewEventService(db, nil, nil) - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, eventService) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) projectDef := &composetypes.Project{ Name: "compose-selected", @@ -839,9 +851,9 @@ func TestProjectService_ComposePullSelectedServicesInternal_LeavesRecordsWhenPul repository := "registry.example.com/team/app" dockerService := &DockerClientService{} - imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil) + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) imageService := NewImageService(db, dockerService, nil, imageUpdateService, nil, NewEventService(db, nil, nil)) - svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, dockerService, nil, nil, config.Load()) projectDef := &composetypes.Project{ Name: "compose-selected", @@ -883,6 +895,122 @@ func TestProjectService_ComposePullSelectedServicesInternal_LeavesRecordsWhenPul assert.Zero(t, count) } +func TestProjectService_UpdateProjectServicesHardFailsWhenPullFailsInternal(t *testing.T) { + ctx := context.Background() + db := setupProjectTestDB(t) + projectsDir := t.TempDir() + t.Setenv("PROJECTS_DIRECTORY", projectsDir) + + settingsService, err := NewSettingsService(ctx, db) + require.NoError(t, err) + require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) + + projectPath := createComposeProjectDir(t, projectsDir, "compose-update-pull-fail") + require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: registry.example.com/team/app:9.9.9\n"), 0o644)) + + projectRecord := &models.Project{ + BaseModel: models.BaseModel{ID: "project-update-pull-fail"}, + Name: "compose-update-pull-fail", + DirName: ptr("compose-update-pull-fail"), + Path: projectPath, + Status: models.ProjectStatusRunning, + } + require.NoError(t, db.Create(projectRecord).Error) + require.NoError(t, db.Create(&models.ImageUpdateRecord{ + ID: "sha256:selected-old", + Repository: "registry.example.com/team/app", + Tag: "9.9.9", + HasUpdate: true, + UpdateType: models.UpdateTypeDigest, + CurrentVersion: "9.9.9", + CheckTime: time.Now().UTC().Add(-time.Hour), + }).Error) + + originalComposePull := composePullProjectServicesInternal + originalComposeUp := composeUpProjectServicesInternal + t.Cleanup(func() { + composePullProjectServicesInternal = originalComposePull + composeUpProjectServicesInternal = originalComposeUp + }) + + composePullProjectServicesInternal = func(context.Context, *composetypes.Project, []string) error { + return errors.New("compose pull failed") + } + upCalled := false + composeUpProjectServicesInternal = func(context.Context, *composetypes.Project, []string, bool, bool) error { + upCalled = true + return errors.New("compose up should not run") + } + + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) + err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) + require.Error(t, err) + assert.ErrorContains(t, err, "pull updated service images") + assert.False(t, upCalled, "compose up must not run after a pull failure") + + var persistedProject models.Project + require.NoError(t, db.WithContext(ctx).Where("id = ?", projectRecord.ID).First(&persistedProject).Error) + assert.Equal(t, models.ProjectStatusRunning, persistedProject.Status) + + var persistedRecord models.ImageUpdateRecord + require.NoError(t, db.WithContext(ctx).Where("id = ?", "sha256:selected-old").First(&persistedRecord).Error) + assert.True(t, persistedRecord.HasUpdate) +} + +func TestProjectService_UpdateProjectServicesForcesRecreateInternal(t *testing.T) { + ctx := context.Background() + db := setupProjectTestDB(t) + projectsDir := t.TempDir() + t.Setenv("PROJECTS_DIRECTORY", projectsDir) + + settingsService, err := NewSettingsService(ctx, db) + require.NoError(t, err) + require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) + + projectPath := createComposeProjectDir(t, projectsDir, "compose-update-force") + require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: registry.example.com/team/app:9.9.9\n"), 0o644)) + + projectRecord := &models.Project{ + BaseModel: models.BaseModel{ID: "project-update-force"}, + Name: "compose-update-force", + DirName: ptr("compose-update-force"), + Path: projectPath, + Status: models.ProjectStatusRunning, + } + require.NoError(t, db.Create(projectRecord).Error) + + originalComposePull := composePullProjectServicesInternal + originalComposeStop := composeStopProjectServicesInternal + originalComposeUp := composeUpProjectServicesInternal + t.Cleanup(func() { + composePullProjectServicesInternal = originalComposePull + composeStopProjectServicesInternal = originalComposeStop + composeUpProjectServicesInternal = originalComposeUp + }) + + composePullProjectServicesInternal = func(context.Context, *composetypes.Project, []string) error { + return nil + } + composeStopProjectServicesInternal = func(context.Context, *composetypes.Project, []string) error { + return nil + } + upCalled := false + forceRecreate := false + composeUpProjectServicesInternal = func(_ context.Context, _ *composetypes.Project, services []string, removeOrphans bool, force bool) error { + upCalled = true + forceRecreate = force + assert.Equal(t, []string{"app"}, services) + assert.False(t, removeOrphans) + return errors.New("compose up failed after assertion") + } + + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) + err = svc.UpdateProjectServices(ctx, projectRecord.ID, []string{"app"}, systemUser) + require.Error(t, err) + assert.True(t, upCalled) + assert.True(t, forceRecreate, "service updates must force recreate after pulling the updated image") +} + func TestProjectService_UpdateProject_RenamesDirectoryWhenNameChanges(t *testing.T) { db := setupProjectTestDB(t) ctx := context.Background() @@ -894,7 +1022,7 @@ func TestProjectService_UpdateProject_RenamesDirectoryWhenNameChanges(t *testing require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -942,7 +1070,7 @@ func TestProjectService_UpdateProject_RenameFailsWhenTargetDirectoryExists(t *te require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -988,7 +1116,7 @@ func TestProjectService_UpdateProject_RenameFailsWhenProjectRunning(t *testing.T require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) originalDirName := "Foo" originalPath := filepath.Join(projectsDir, originalDirName) @@ -1031,7 +1159,7 @@ func TestProjectService_UpdateProject_ValidatesComposeUsingExistingProjectName(t require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "demo" projectPath := filepath.Join(projectsDir, dirName) @@ -1073,7 +1201,7 @@ func TestProjectService_UpdateProject_AllowsMissingEnvFileDuringComposeValidatio require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-required" projectPath := filepath.Join(projectsDir, dirName) @@ -1117,7 +1245,7 @@ func TestProjectService_UpdateProject_AllowsMissingLocalIncludeDuringComposeVali require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-new" projectPath := filepath.Join(projectsDir, dirName) @@ -1181,7 +1309,7 @@ func TestProjectService_UpdateProject_RejectsMissingExternalIncludeDuringCompose require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-external" projectPath := filepath.Join(projectsDir, dirName) @@ -1225,7 +1353,7 @@ func TestProjectService_CreateProject_RejectsExternalInclude(t *testing.T) { require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644)) compose := `include: @@ -1261,7 +1389,7 @@ func TestProjectService_CreateProject_RejectsArrayPathInclude(t *testing.T) { require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644)) compose := `include: @@ -1299,7 +1427,7 @@ func TestProjectService_GetProjectFileContent_RejectsExternalInclude(t *testing. require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-read" projectPath := filepath.Join(projectsDir, dirName) @@ -1342,7 +1470,7 @@ func TestProjectService_GetProjectFileContent_RejectsSymlinkInclude(t *testing.T require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-symlink" projectPath := filepath.Join(projectsDir, dirName) @@ -1388,7 +1516,7 @@ func TestProjectService_GetProjectFileContent_RejectsIntermediateSymlinkInclude( require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "include-intermediate-symlink" projectPath := filepath.Join(projectsDir, dirName) @@ -1434,7 +1562,7 @@ func TestProjectService_UpdateProject_UsesExistingEnvFileDuringComposeValidation require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-existing" projectPath := filepath.Join(projectsDir, dirName) @@ -1480,7 +1608,7 @@ func TestProjectService_UpdateProject_UsesProvidedEnvContentDuringComposeValidat require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-updated" projectPath := filepath.Join(projectsDir, dirName) @@ -1526,7 +1654,7 @@ func TestProjectService_UpdateProject_ReturnsEnvParseErrorDuringComposeValidatio require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "env-invalid" projectPath := filepath.Join(projectsDir, dirName) @@ -1570,7 +1698,7 @@ func TestProjectService_UpdateProject_UsesGlobalEnvDuringComposeValidation(t *te require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "global-env-update" projectPath := filepath.Join(projectsDir, dirName) @@ -1618,7 +1746,7 @@ func TestProjectService_UpdateProject_DoesNotResolveHostEnvThroughGlobalEnvDurin require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "host-env-guard" projectPath := filepath.Join(projectsDir, dirName) @@ -1661,7 +1789,7 @@ func TestProjectService_UpdateProject_DerivesProjectOverrideEnvWhenGitSourceExis require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "override-edit" projectPath := filepath.Join(projectsDir, dirName) @@ -1707,7 +1835,7 @@ func TestProjectService_UpdateProject_DeletingGitBackedKeyFallsBackToGit(t *test require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "override-delete" projectPath := filepath.Join(projectsDir, dirName) @@ -1756,7 +1884,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_MigratesDirectEnvIntoProjectOve require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-migrate" projectPath := filepath.Join(projectsDir, dirName) @@ -1807,7 +1935,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_NormalizesStaleCopiedGitOverrid require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-normalize" projectPath := filepath.Join(projectsDir, dirName) @@ -1855,7 +1983,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_RemovesLegacyDeletedGitMasks(t require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-delete-mask" projectPath := filepath.Join(projectsDir, dirName) @@ -1904,7 +2032,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_RemovesGitEnvSource(t *testing. require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-remove" projectPath := filepath.Join(projectsDir, dirName) @@ -1948,7 +2076,7 @@ func TestProjectService_ApplyGitSyncProjectFiles_UsesGlobalEnvDuringComposeValid require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-global-env" projectPath := filepath.Join(projectsDir, dirName) @@ -1995,7 +2123,7 @@ func TestProjectService_PersistGitSyncEnvFiles_UsesPreparedState(t *testing.T) { require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "git-sync-prepared-state" projectPath := filepath.Join(projectsDir, dirName) @@ -2033,7 +2161,7 @@ func TestProjectService_GetProjectDetails_ReturnsEffectiveEnvContent(t *testing. require.NoError(t, err) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) dirName := "details-override" projectPath := filepath.Join(projectsDir, dirName) @@ -2160,7 +2288,7 @@ func TestProjectService_GetProjectDetails_IncludesUpdateInfo(t *testing.T) { require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) imageService := &ImageService{db: db} - svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, nil, config.Load()) projectPath := createComposeProjectDir(t, projectsDir, "updates-demo") require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: nginx:latest\n"), 0o644)) @@ -2259,7 +2387,7 @@ func TestProjectService_GetProjectDetails_RefreshesRuntimeStatusWithoutRuntimeSe } require.NoError(t, db.Create(projectRecord).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) details, err := svc.GetProjectDetails(ctx, projectRecord.ID, projecttypes.DetailsOptions{}) require.NoError(t, err) @@ -2311,7 +2439,7 @@ func TestProjectService_GetProjectDetails_PopulatesRuntimeServicesFromComposePs( } require.NoError(t, db.Create(projectRecord).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) details, err := svc.GetProjectDetails(ctx, projectRecord.ID, projecttypes.DetailsOptions{IncludeRuntimeServices: true}) require.NoError(t, err) @@ -2335,7 +2463,7 @@ func TestProjectService_ListProjects_FiltersByUpdateStatus(t *testing.T) { require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsDir)) imageService := &ImageService{db: db} - svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, imageService, nil, nil, nil, config.Load()) updatedPath := createComposeProjectDir(t, projectsDir, "updated-demo") require.NoError(t, os.WriteFile(filepath.Join(updatedPath, "compose.yaml"), []byte("services:\n app:\n image: nginx:latest\n"), 0o644)) @@ -2422,8 +2550,8 @@ func TestProjectService_ListProjects_FiltersByUpdateStatus(t *testing.T) { Filters: map[string]string{ "updates": tt.filter, }, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, len(tt.expected), page.TotalItems) @@ -2549,8 +2677,6 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { activePath := createComposeProjectDir(t, projectsRoot, "active-demo") archivedPath := createComposeProjectDir(t, projectsRoot, "archived-demo") - archivedAt := time.Now().UTC() - require.NoError(t, db.Create(&models.Project{ BaseModel: models.BaseModel{ID: "project-active"}, Name: "active-demo", @@ -2565,14 +2691,14 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { Path: archivedPath, Status: models.ProjectStatusStopped, IsArchived: true, - ArchivedAt: &archivedAt, + ArchivedAt: new(time.Now().UTC()), }).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -2580,9 +2706,9 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { assert.Equal(t, "active-demo", items[0].Name) items, page, err = svc.ListProjects(ctx, pagination.QueryParams{ - Filters: map[string]string{"archived": "true"}, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Filters: map[string]string{"archived": "true"}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -2591,9 +2717,9 @@ func TestProjectService_ListProjects_FiltersArchivedProjects(t *testing.T) { assert.True(t, items[0].IsArchived) items, page, err = svc.ListProjects(ctx, pagination.QueryParams{ - Filters: map[string]string{"archived": "all"}, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Filters: map[string]string{"archived": "all"}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) require.EqualValues(t, 2, page.TotalItems) @@ -2620,7 +2746,7 @@ func TestProjectService_ArchiveProject_RequiresStoppedProject(t *testing.T) { RunningCount: 1, }).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) err = svc.ArchiveProject(ctx, "project-running", models.User{BaseModel: models.BaseModel{ID: "user-1"}, Username: "tester"}) require.Error(t, err) var stoppedErr *common.ProjectMustBeStoppedError @@ -2650,7 +2776,7 @@ func TestProjectService_ArchiveProject_TogglesArchiveFlag(t *testing.T) { Status: models.ProjectStatusStopped, }).Error) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) user := models.User{BaseModel: models.BaseModel{ID: "user-1"}, Username: "tester"} require.NoError(t, svc.ArchiveProject(ctx, "project-stopped", user)) @@ -2784,14 +2910,14 @@ func TestProjectService_ListProjects_WithDerivedStatusFilter_AllowsAllPageSizeSe }).Error) } - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ Filters: map[string]string{ "status": string(models.ProjectStatusStopped), }, - PaginationParams: pagination.PaginationParams{Limit: -1}, - SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, + SortParams: pagination.SortParams{Sort: "name", Order: pagination.SortAsc}, }) require.NoError(t, err) assert.EqualValues(t, 25, page.TotalItems) @@ -2871,7 +2997,6 @@ func TestProjectService_PrepareServiceBuildRequest_MapsComposeFields(t *testing. "web", serviceCfg, ProjectBuildOptions{}, - nil, ) require.NoError(t, err) @@ -2906,7 +3031,6 @@ func TestProjectService_PrepareServiceBuildRequest_MapsComposeFields(t *testing. func TestProjectService_PrepareServiceBuildRequest_KeepsContainerPaths(t *testing.T) { svc := &ProjectService{} proj := &composetypes.Project{WorkingDir: "/app/data/projects/demo", Name: "demo"} - pm := projects.NewPathMapper("/app/data/projects", "/docker-data/arcane/projects") serviceCfg := composetypes.ServiceConfig{ Name: "web", @@ -2924,7 +3048,6 @@ func TestProjectService_PrepareServiceBuildRequest_KeepsContainerPaths(t *testin "web", serviceCfg, ProjectBuildOptions{}, - pm, ) require.NoError(t, err) @@ -2941,7 +3064,6 @@ func TestProjectService_PrepareServiceBuildRequest_KeepsContainerPaths(t *testin func TestProjectService_PrepareServiceBuildRequest_BuildDotKeepsContainerPath(t *testing.T) { svc := &ProjectService{} proj := &composetypes.Project{WorkingDir: "/app/data/projects/caddy", Name: "caddy"} - pm := projects.NewPathMapper("/app/data/projects", "/storage/volumes/arcane/projects") serviceCfg := composetypes.ServiceConfig{ Name: "caddy", @@ -2958,7 +3080,6 @@ func TestProjectService_PrepareServiceBuildRequest_BuildDotKeepsContainerPath(t "caddy", serviceCfg, ProjectBuildOptions{}, - pm, ) require.NoError(t, err) @@ -2986,7 +3107,6 @@ func TestProjectService_PrepareServiceBuildRequest_UsesInlineDockerfile(t *testi "web", serviceCfg, ProjectBuildOptions{}, - nil, ) require.NoError(t, err) @@ -3054,7 +3174,6 @@ func TestProjectService_PrepareServiceBuildRequest_GeneratedImageProviderGuardra "web", serviceCfg, ProjectBuildOptions{Provider: "depot"}, - nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "must define an image when using depot") @@ -3066,7 +3185,6 @@ func TestProjectService_PrepareServiceBuildRequest_GeneratedImageProviderGuardra "web", serviceCfg, ProjectBuildOptions{Provider: "local", Push: new(true)}, - nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "must define an image when push is enabled") @@ -3098,7 +3216,7 @@ func TestProjectService_DeployProject_StopsOnBuildPreparationError(t *testing.T) require.NoError(t, db.Create(proj).Error) buildSvc := &BuildService{builder: testBuildBuilder{err: errors.New("boom build")}} - svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, nil, config.Load()) err = svc.DeployProject(ctx, "p1", models.User{BaseModel: models.BaseModel{ID: "u1"}, Username: "tester"}, nil) require.Error(t, err) @@ -3140,7 +3258,7 @@ func TestProjectService_DeployProject_BuildsGeneratedImageWithoutPull(t *testing require.NoError(t, db.Create(proj).Error) buildSvc := &BuildService{builder: testBuildBuilder{err: errors.New("boom build")}} - svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, buildSvc, nil, config.Load()) err = svc.DeployProject(ctx, proj.ID, models.User{BaseModel: models.BaseModel{ID: "u1"}, Username: "tester"}, nil) require.Error(t, err) @@ -3190,7 +3308,7 @@ func TestProjectService_SyncProjectsFromFileSystem_IgnoresSymlinkedProjectDirsWh require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3216,7 +3334,7 @@ func TestProjectService_SyncProjectsFromFileSystem_DetectsSymlinkedProjectDirsWh require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3241,7 +3359,7 @@ func TestProjectService_CountProjectFolders_RespectsFollowProjectSymlinks(t *tes require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false")) count, err := svc.countProjectFolders(ctx) @@ -3267,12 +3385,12 @@ func TestProjectService_SyncProjectsFromFileSystem_DiscoversNestedProjectsAndRel require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, - PaginationParams: pagination.PaginationParams{Limit: -1}, + SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, }) require.NoError(t, err) require.EqualValues(t, 2, page.TotalItems) @@ -3301,7 +3419,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RespectsConfiguredScanMaxDept require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) t.Setenv("PROJECT_SCAN_MAX_DEPTH", "1") - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3346,12 +3464,12 @@ services: require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, - PaginationParams: pagination.PaginationParams{Limit: -1}, + SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -3373,7 +3491,7 @@ func TestProjectService_CountProjectFolders_RecursivelyCountsNestedProjects(t *t require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) count, err := svc.countProjectFolders(ctx) require.NoError(t, err) @@ -3394,7 +3512,7 @@ func TestProjectService_CountProjectFolders_RespectsConfiguredScanMaxDepth(t *te require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) t.Setenv("PROJECT_SCAN_MAX_DEPTH", "1") - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) count, err := svc.countProjectFolders(ctx) require.NoError(t, err) @@ -3413,7 +3531,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RemovesDeletedNestedProject(t require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3447,7 +3565,7 @@ func TestProjectService_SyncProjectsFromFileSystem_PreservesDBRecordsWhenDirecto require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) before, err := svc.ListAllProjects(ctx) @@ -3482,7 +3600,7 @@ func TestProjectService_SyncProjectsFromFileSystem_AllowsDuplicateLeafDirectorie require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) var items []models.Project @@ -3513,12 +3631,12 @@ func TestProjectService_SyncProjectsFromFileSystem_DetectsNestedSymlinkedProject require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, page, err := svc.ListProjects(ctx, pagination.QueryParams{ - SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, - PaginationParams: pagination.PaginationParams{Limit: -1}, + SortParams: pagination.SortParams{Sort: "path", Order: pagination.SortAsc}, + Params: pagination.Params{Limit: -1}, }) require.NoError(t, err) require.EqualValues(t, 1, page.TotalItems) @@ -3544,7 +3662,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RemovesSymlinkedProjectsWhenD require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3574,7 +3692,7 @@ func TestProjectService_SyncProjectsFromFileSystem_RefreshesServiceCountOnCompos require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) var project models.Project @@ -3628,7 +3746,7 @@ func TestProjectService_SyncProjectsFromFileSystem_PreservesGitOpsProjectWithCus require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) require.NoError(t, svc.SyncProjectsFromFileSystem(ctx)) items, err := svc.ListAllProjects(ctx) @@ -3678,7 +3796,7 @@ func TestProjectService_GetProjectDetails_UsesGitOpsCustomComposeFilename(t *tes require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot)) - svc := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, nil, nil, nil, nil, nil, config.Load()) composeFromContent, envFromContent, err := svc.GetProjectContent(ctx, syncProjectID) require.NoError(t, err) @@ -3710,7 +3828,7 @@ func TestProjectService_UpdateProject_WritesThroughSymlinkedProjectPath(t *testi require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true")) eventService := NewEventService(db, nil, nil) - svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load()) + svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, nil, config.Load()) project := &models.Project{ BaseModel: models.BaseModel{ID: "proj-symlink-update"}, diff --git a/backend/internal/services/role_service.go b/backend/internal/services/role_service.go new file mode 100644 index 0000000000..cc78de3417 --- /dev/null +++ b/backend/internal/services/role_service.go @@ -0,0 +1,954 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/database" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/getarcaneapp/arcane/backend/pkg/pagination" + pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils" + "github.com/getarcaneapp/arcane/backend/pkg/utils/cache" + "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" + roletypes "github.com/getarcaneapp/arcane/types/role" +) + +// permissionCacheTTL bounds how long a resolved PermissionSet is reused +// before re-querying the DB. The service also invalidates entries explicitly +// on mutation paths, so this TTL is a safety net. +const permissionCacheTTL = 60 * time.Second + +// RoleService owns role definitions, user role assignments, OIDC role +// mappings, and API key permissions. It resolves a caller's effective +// PermissionSet on demand and caches the result per-user / per-key for a +// short TTL to keep the hot path off the database. +type RoleService struct { + db *database.DB + userCache *cache.TTL[*authz.PermissionSet] + apiKeyCache *cache.TTL[*authz.PermissionSet] +} + +func NewRoleService(db *database.DB) *RoleService { + return &RoleService{ + db: db, + userCache: cache.NewTTL[*authz.PermissionSet](permissionCacheTTL), + apiKeyCache: cache.NewTTL[*authz.PermissionSet](permissionCacheTTL), + } +} + +// ---------- Boot-time reconciliation & safety checks ---------- + +// EnsureBuiltInRoles overwrites the permission set on every built-in role to +// match the Go constants. Idempotent. Called at boot after migrations succeed. +func (s *RoleService) EnsureBuiltInRoles(ctx context.Context) error { + builtIns := map[string]struct { + name string + desc string + perm []string + }{ + authz.BuiltInRoleAdmin: {"Admin", "Full administrative access", authz.AllPermissions()}, + authz.BuiltInRoleEditor: {"Editor", "Read and write on Docker resources", authz.BuiltInEditorPermissions()}, + authz.BuiltInRoleNoShellEditor: {"No-Shell Editor", "Editor without interactive container shell access", authz.BuiltInNoShellEditorPermissions()}, + authz.BuiltInRoleDeployer: {"Deployer", "Deploy and lifecycle containers and projects", authz.BuiltInDeployerPermissions()}, + authz.BuiltInRoleMonitor: {"Monitor", "Observability-only access: logs, dashboards, events", authz.BuiltInMonitorPermissions()}, + authz.BuiltInRoleViewer: {"Viewer", "Read-only access to all resources", authz.BuiltInViewerPermissions()}, + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + for id, spec := range builtIns { + role := models.Role{ + BaseModel: models.BaseModel{ID: id}, + Name: spec.name, + Description: new(spec.desc), + Permissions: models.StringSlice(spec.perm), + BuiltIn: true, + } + if err := tx.Save(&role).Error; err != nil { + return fmt.Errorf("failed to upsert built-in role %s: %w", id, err) + } + } + return nil + }) +} + +// BackfillLegacyRoleAssignments migrates the pre-RBAC users.roles JSON column +// into rows in user_role_assignments. Safe to call on every boot: a no-op once +// the column is gone. +// +// Users with "admin" in their legacy roles get a global Admin assignment; +// every other user gets a global Viewer assignment. The NULL environment_id +// lands the perms in PermissionSet.Global, which is what ps.Allows(perm, "") +// consults for org-level checks (list environments, read settings, list users, +// etc.) AND for env-scoped checks at the union step. Inserting per-environment +// viewer rows instead would lock non-admins out of the settings area entirely. +// +// Lives here (not as a SQL migration) so the column-existence check is trivial +// in Go and the same code path covers both postgres and sqlite. Idempotent via +// ON CONFLICT DO NOTHING on the (user_id, role_id, env) unique index, so a +// half-finished prior run can be safely retried. +func (s *RoleService) BackfillLegacyRoleAssignments(ctx context.Context) error { + migrator := s.db.WithContext(ctx).Migrator() + if !migrator.HasColumn("users", "roles") { + return nil + } + + type legacyUser struct { + ID string `gorm:"column:id"` + Roles string `gorm:"column:roles"` + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var rows []legacyUser + if err := tx.Table("users").Select("id, roles").Scan(&rows).Error; err != nil { + return fmt.Errorf("failed to read legacy users.roles for backfill: %w", err) + } + for _, u := range rows { + roleID := authz.BuiltInRoleViewer + if legacyRolesContainsAdminInternal(u.Roles) { + roleID = authz.BuiltInRoleAdmin + } + assignment := models.UserRoleAssignment{ + UserID: u.ID, + RoleID: roleID, + Source: models.RoleAssignmentSourceManual, + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&assignment).Error; err != nil { + return fmt.Errorf("failed to backfill assignment for user %s: %w", u.ID, err) + } + } + slog.InfoContext(ctx, "Backfilled legacy users.roles into user_role_assignments", "userCount", len(rows)) + return nil + }) +} + +// legacyRolesContainsAdminInternal reports whether a pre-RBAC users.roles JSON +// value contains the literal "admin" (case-insensitive). Empty / null / malformed +// JSON yields false — treat as non-admin and assign Viewer. +func legacyRolesContainsAdminInternal(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" || raw == "null" { + return false + } + var roles []string + if err := json.Unmarshal([]byte(raw), &roles); err != nil { + return false + } + for _, r := range roles { + if strings.EqualFold(strings.TrimSpace(r), "admin") { + return true + } + } + return false +} + +// AssertGlobalAdminExists returns a *common.NoGlobalAdminRemainsError if zero +// users hold a globally-scoped Admin role assignment. Called at boot after the +// backfill migration; also called from inside mutation paths. +func (s *RoleService) AssertGlobalAdminExists(ctx context.Context) error { + count, err := s.countGlobalAdminsInternal(ctx, s.db.WithContext(ctx)) + if err != nil { + return err + } + if count == 0 { + return &common.NoGlobalAdminRemainsError{} + } + return nil +} + +// BackfillApiKeyPermissions populates api_key_permissions for every existing +// API key whose row has no permissions yet. Each key inherits a snapshot of +// its owner's current effective permissions (scoped per the key's +// environment_id when set). Idempotent: skips if the table is non-empty. +// BackfillApiKeyPermissions ensures every ownerless (bootstrap) API key has +// its expected permission grants. Called once per boot. +// +// Per-key, not all-or-nothing: a single bootstrap key with zero grants is +// repaired even if other keys are already populated. This recovers env- +// bootstrap keys that pre-date the per-key permission feature, or that were +// created on a deployment where the original SetApiKeyPermissions call failed +// (e.g., the api_key_permissions table didn't exist yet). +// +// User-owned keys are deliberately skipped. A user-owned key with zero grants +// is an intentional "no access" state; rehydrating from the owner's effective +// permissions on every boot would clobber that. User keys are seeded at +// creation time by CreateApiKey instead. +func (s *RoleService) BackfillApiKeyPermissions(ctx context.Context) error { + // Bootstrap-class keys we'll repair if they have zero grants: + // - user_id IS NULL → env-bootstrap keys. + // - managed_by IS NOT NULL → admin-static (ADMIN_STATIC_API_KEY) keys, + // which DO have a user_id but are infrastructure-managed and must + // always carry full perms. + // Regular user-created keys are deliberately excluded — empty grants on + // those are an intentional "no access" state we must not overwrite. + var keys []models.ApiKey + if err := s.db.WithContext(ctx).Where("user_id IS NULL OR managed_by IS NOT NULL").Find(&keys).Error; err != nil { + return fmt.Errorf("failed to list bootstrap api keys for backfill: %w", err) + } + if len(keys) == 0 { + return nil + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + for _, key := range keys { + var existing int64 + if err := tx.Model(&models.ApiKeyPermission{}).Where("api_key_id = ?", key.ID).Count(&existing).Error; err != nil { + return fmt.Errorf("failed to count permissions for api key %s: %w", key.ID, err) + } + if existing > 0 { + continue + } + perms, err := s.backfillPermsForKeyInternal(ctx, tx, key) + if err != nil { + return err + } + for _, p := range perms { + if err := tx.Create(&models.ApiKeyPermission{ + ApiKeyID: key.ID, + Permission: p, + EnvironmentID: key.EnvironmentID, + }).Error; err != nil { + return fmt.Errorf("failed to seed api key permission: %w", err) + } + } + slog.InfoContext(ctx, "Backfilled missing permissions for bootstrap api key", "api_key_id", key.ID, "perm_count", len(perms), "env_id", key.EnvironmentID) + } + return nil + }) +} + +func (s *RoleService) backfillPermsForKeyInternal(ctx context.Context, tx *gorm.DB, key models.ApiKey) ([]string, error) { + // Static admin bootstrap keys (no owner, no env scope) get full access. + if key.UserID == nil && key.EnvironmentID == nil { + return authz.AllPermissions(), nil + } + // Environment bootstrap keys (key bound to a specific env, no owner) get + // full access scoped to that environment via the auth bridge — replicate + // that by granting all permissions scoped to that environment. Org-level + // permissions land in PerEnv[envID] and are unreachable via org-level + // checks (which always pass envID=""), so over-granting is harmless here. + if key.UserID == nil && key.EnvironmentID != nil { + return authz.AllPermissions(), nil + } + // Otherwise inherit the owner's current effective permissions. + var owner models.User + if err := tx.WithContext(ctx).Where("id = ?", *key.UserID).First(&owner).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to load api key owner: %w", err) + } + ps, err := s.resolveUserPermissionsInternal(ctx, tx, owner.ID) + if err != nil { + return nil, err + } + // Flatten ps into a deduplicated list of permissions for this key's scope. + seen := make(map[string]struct{}, len(ps.Global)) + for p := range ps.Global { + seen[p] = struct{}{} + } + if key.EnvironmentID != nil { + if env, ok := ps.PerEnv[*key.EnvironmentID]; ok { + for p := range env { + seen[p] = struct{}{} + } + } + } + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + return out, nil +} + +// ---------- Role CRUD ---------- + +func (s *RoleService) ListRoles(ctx context.Context, params pagination.QueryParams) ([]models.Role, pagination.Response, error) { + var roles []models.Role + query := s.db.WithContext(ctx).Model(&models.Role{}) + + if term := strings.TrimSpace(params.Search); term != "" { + pattern := "%" + term + "%" + query = query.Where("name LIKE ? OR COALESCE(description, '') LIKE ?", pattern, pattern) + } + + resp, err := pagination.PaginateAndSortDB(params, query, &roles) + if err != nil { + return nil, pagination.Response{}, fmt.Errorf("failed to paginate roles: %w", err) + } + return roles, resp, nil +} + +func (s *RoleService) ListAllRoles(ctx context.Context) ([]models.Role, error) { + var roles []models.Role + if err := s.db.WithContext(ctx).Order("name").Find(&roles).Error; err != nil { + return nil, fmt.Errorf("failed to list roles: %w", err) + } + return roles, nil +} + +func (s *RoleService) GetRole(ctx context.Context, id string) (*models.Role, error) { + return dbutil.FirstWhere[models.Role](ctx, s.db.DB, &common.RoleNotFoundError{}, "id = ?", id) +} + +func (s *RoleService) CreateRole(ctx context.Context, name string, description *string, permissions []string) (*models.Role, error) { + if strings.TrimSpace(name) == "" { + return nil, errors.New("role name is required") + } + if err := validatePermissionsInternal(permissions); err != nil { + return nil, err + } + role := &models.Role{ + Name: name, + Description: description, + Permissions: models.StringSlice(permissions), + BuiltIn: false, + } + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var conflict int64 + if err := tx.Model(&models.Role{}).Where("name = ?", name).Count(&conflict).Error; err != nil { + return fmt.Errorf("failed to check role name uniqueness: %w", err) + } + if conflict > 0 { + return &common.RoleNameTakenError{} + } + return tx.Create(role).Error + }) + if err != nil { + return nil, err + } + return role, nil +} + +func (s *RoleService) UpdateRole(ctx context.Context, id, name string, description *string, permissions []string) (*models.Role, error) { + if err := validatePermissionsInternal(permissions); err != nil { + return nil, err + } + var out models.Role + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.Role + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.RoleNotFoundError{} + } + return fmt.Errorf("failed to load role: %w", err) + } + if existing.BuiltIn { + return &common.RoleBuiltInError{} + } + if name != existing.Name { + var conflict int64 + if err := tx.Model(&models.Role{}).Where("name = ? AND id <> ?", name, id).Count(&conflict).Error; err != nil { + return fmt.Errorf("failed to check role name uniqueness: %w", err) + } + if conflict > 0 { + return &common.RoleNameTakenError{} + } + } + existing.Name = name + existing.Description = description + existing.Permissions = models.StringSlice(permissions) + if err := tx.Save(&existing).Error; err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + out = existing + return nil + }) + if err != nil { + return nil, err + } + s.invalidateUsersAssignedToInternal(ctx, id) + return &out, nil +} + +func (s *RoleService) DeleteRole(ctx context.Context, id string) error { + var affected []string + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.Role + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.RoleNotFoundError{} + } + return fmt.Errorf("failed to load role: %w", err) + } + if existing.BuiltIn { + return &common.RoleBuiltInError{} + } + // Collect users affected before the delete so we can invalidate their caches. + if err := tx.Model(&models.UserRoleAssignment{}). + Where("role_id = ?", id). + Distinct("user_id"). + Pluck("user_id", &affected).Error; err != nil { + return fmt.Errorf("failed to list affected users: %w", err) + } + if err := tx.Delete(&models.Role{}, "id = ?", id).Error; err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + return nil + }) + if err != nil { + return err + } + // Invalidate caches AFTER the transaction commits so a concurrent + // cache-miss cannot re-populate with stale data from the not-yet-visible + // delete. Consistent with UpdateRole / SetUserAssignments. + for _, uid := range affected { + s.userCache.Delete(uid) + } + return nil +} + +// CountUsersAssignedToRole returns how many distinct users hold an assignment +// to the given role (any source, any environment scope). +func (s *RoleService) CountUsersAssignedToRole(ctx context.Context, roleID string) (int, error) { + var count int64 + if err := s.db.WithContext(ctx). + Model(&models.UserRoleAssignment{}). + Distinct("user_id"). + Where("role_id = ?", roleID). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count users assigned to role: %w", err) + } + return int(count), nil +} + +// ---------- User role assignments ---------- + +func (s *RoleService) ListUserAssignments(ctx context.Context, userID string) ([]models.UserRoleAssignment, error) { + var out []models.UserRoleAssignment + if err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("source ASC, role_id ASC"). + Find(&out).Error; err != nil { + return nil, fmt.Errorf("failed to list user assignments: %w", err) + } + return out, nil +} + +// SetUserAssignments replaces the user's source='manual' assignments with the +// given desired set. Source='oidc' rows are preserved (use +// ReplaceOidcAssignments for those). Enforces the global-admin guard. +func (s *RoleService) SetUserAssignments(ctx context.Context, userID string, desired []models.UserRoleAssignment) error { + for i := range desired { + desired[i].UserID = userID + desired[i].Source = models.RoleAssignmentSourceManual + } + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + // Validate inside the tx so a concurrent role/env delete can't race past + // this check. Yields a typed error → 400 at the handler, rather than an + // opaque FK violation surfacing as 500. + if err := validateAssignmentsExistInternal(tx, desired); err != nil { + return err + } + if err := tx.Where("user_id = ? AND source = ?", userID, models.RoleAssignmentSourceManual). + Delete(&models.UserRoleAssignment{}).Error; err != nil { + return fmt.Errorf("failed to clear manual assignments: %w", err) + } + if len(desired) > 0 { + if err := tx.Create(&desired).Error; err != nil { + return fmt.Errorf("failed to insert assignments: %w", err) + } + } + count, err := s.countGlobalAdminsInternal(ctx, tx) + if err != nil { + return err + } + if count == 0 { + return &common.NoGlobalAdminRemainsError{} + } + return nil + }) + if err != nil { + return err + } + s.userCache.Delete(userID) + return nil +} + +// validateAssignmentsExistInternal verifies every distinct RoleID and +// EnvironmentID referenced by `desired` exists in the database. Returns the +// first missing reference wrapped in an InvalidRoleAssignmentError so the +// handler can map it to a 400 with a descriptive message. +func validateAssignmentsExistInternal(tx *gorm.DB, desired []models.UserRoleAssignment) error { + roleIDSet := make(map[string]struct{}, len(desired)) + envIDSet := make(map[string]struct{}, len(desired)) + for _, a := range desired { + roleIDSet[a.RoleID] = struct{}{} + if a.EnvironmentID != nil { + envIDSet[*a.EnvironmentID] = struct{}{} + } + } + + if len(roleIDSet) > 0 { + roleIDs := make([]string, 0, len(roleIDSet)) + for id := range roleIDSet { + roleIDs = append(roleIDs, id) + } + var found []string + if err := tx.Model(&models.Role{}).Where("id IN ?", roleIDs).Pluck("id", &found).Error; err != nil { + return fmt.Errorf("failed to verify role ids: %w", err) + } + foundSet := make(map[string]struct{}, len(found)) + for _, id := range found { + foundSet[id] = struct{}{} + } + for id := range roleIDSet { + if _, ok := foundSet[id]; !ok { + return &common.InvalidRoleAssignmentError{RoleID: id} + } + } + } + + if len(envIDSet) > 0 { + envIDs := make([]string, 0, len(envIDSet)) + for id := range envIDSet { + envIDs = append(envIDs, id) + } + var found []string + if err := tx.Model(&models.Environment{}).Where("id IN ?", envIDs).Pluck("id", &found).Error; err != nil { + return fmt.Errorf("failed to verify environment ids: %w", err) + } + foundSet := make(map[string]struct{}, len(found)) + for _, id := range found { + foundSet[id] = struct{}{} + } + for id := range envIDSet { + if _, ok := foundSet[id]; !ok { + return &common.InvalidRoleAssignmentError{EnvironmentID: id} + } + } + } + return nil +} + +// ReplaceOidcAssignments replaces the user's source='oidc' assignments. Manual +// assignments are untouched. Enforces the global-admin guard after the swap. +func (s *RoleService) ReplaceOidcAssignments(ctx context.Context, userID string, desired []models.UserRoleAssignment) error { + for i := range desired { + desired[i].UserID = userID + desired[i].Source = models.RoleAssignmentSourceOidc + } + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + if err := tx.Where("user_id = ? AND source = ?", userID, models.RoleAssignmentSourceOidc). + Delete(&models.UserRoleAssignment{}).Error; err != nil { + return fmt.Errorf("failed to clear oidc assignments: %w", err) + } + if len(desired) > 0 { + if err := tx.Create(&desired).Error; err != nil { + return fmt.Errorf("failed to insert oidc assignments: %w", err) + } + } + count, err := s.countGlobalAdminsInternal(ctx, tx) + if err != nil { + return err + } + if count == 0 { + return &common.NoGlobalAdminRemainsError{} + } + return nil + }) + if err != nil { + return err + } + s.userCache.Delete(userID) + return nil +} + +// CountGlobalAdminsExcludingUser returns the number of distinct users (other +// than excludedUserID) that hold a global Admin assignment. Used as the +// authoritative check for "removing this user / demoting this assignment +// would leave the system with no admin." +func (s *RoleService) CountGlobalAdminsExcludingUser(ctx context.Context, excludedUserID string) (int, error) { + var count int64 + if err := s.db.WithContext(ctx). + Table("user_role_assignments AS ura"). + Joins("JOIN users ON users.id = ura.user_id"). + Distinct("ura.user_id"). + Where("ura.role_id = ? AND ura.environment_id IS NULL AND ura.user_id <> ? AND users.is_service_account = ?", authz.BuiltInRoleAdmin, excludedUserID, false). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count global admins: %w", err) + } + return int(count), nil +} + +func (s *RoleService) countGlobalAdminsInternal(_ context.Context, tx *gorm.DB) (int, error) { + var count int64 + if err := tx.Table("user_role_assignments AS ura"). + Joins("JOIN users ON users.id = ura.user_id"). + Distinct("ura.user_id"). + Where("ura.role_id = ? AND ura.environment_id IS NULL AND users.is_service_account = ?", authz.BuiltInRoleAdmin, false). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count global admins: %w", err) + } + return int(count), nil +} + +// ---------- Permission resolution ---------- + +// ResolvePermissions returns the effective PermissionSet for a user, caching +// the result per-user for permissionCacheTTL. +func (s *RoleService) ResolvePermissions(ctx context.Context, user *models.User) (*authz.PermissionSet, error) { + if user == nil { + return authz.NewPermissionSet(), nil + } + if ps, ok := s.userCache.Get(user.ID); ok { + return ps, nil + } + ps, err := s.resolveUserPermissionsInternal(ctx, s.db.WithContext(ctx), user.ID) + if err != nil { + return nil, err + } + s.userCache.Put(user.ID, ps) + return ps, nil +} + +func (s *RoleService) resolveUserPermissionsInternal(_ context.Context, tx *gorm.DB, userID string) (*authz.PermissionSet, error) { + // Scan into raw string for the permissions JSON column to avoid GORM's + // schema-introspection on anonymous local structs (which can't see the + // type tags needed to wire models.StringSlice's Scanner). + type row struct { + Permissions string `gorm:"column:permissions"` + EnvironmentID *string `gorm:"column:environment_id"` + } + var rows []row + if err := tx.Table("user_role_assignments AS ura"). + Select("r.permissions AS permissions, ura.environment_id AS environment_id"). + Joins("INNER JOIN roles r ON r.id = ura.role_id"). + Where("ura.user_id = ?", userID). + Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to resolve user permissions: %w", err) + } + ps := authz.NewPermissionSet() + for _, r := range rows { + perms, err := decodePermissionsJSONInternal(r.Permissions) + if err != nil { + return nil, fmt.Errorf("failed to decode role permissions: %w", err) + } + if r.EnvironmentID == nil { + ps.AddGlobal(perms...) + } else { + ps.AddEnv(*r.EnvironmentID, perms...) + } + } + return ps, nil +} + +// decodePermissionsJSONInternal parses the JSON-encoded `roles.permissions` column +// into a string slice. The column is `[]` for an empty role. +func decodePermissionsJSONInternal(raw string) ([]string, error) { + if raw == "" || raw == "[]" { + return nil, nil + } + var out []string + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, err + } + return out, nil +} + +// ResolveApiKeyPermissions returns the PermissionSet for an API key. Caches +// per-key. Falls back to an empty set (deny-all) if the key has no perms. +func (s *RoleService) ResolveApiKeyPermissions(ctx context.Context, apiKeyID string) (*authz.PermissionSet, error) { + if ps, ok := s.apiKeyCache.Get(apiKeyID); ok { + return ps, nil + } + var perms []models.ApiKeyPermission + if err := s.db.WithContext(ctx).Where("api_key_id = ?", apiKeyID).Find(&perms).Error; err != nil { + return nil, fmt.Errorf("failed to resolve api key permissions: %w", err) + } + ps := authz.NewPermissionSet() + for _, p := range perms { + if p.EnvironmentID == nil { + ps.AddGlobal(p.Permission) + } else { + ps.AddEnv(*p.EnvironmentID, p.Permission) + } + } + s.apiKeyCache.Put(apiKeyID, ps) + return ps, nil +} + +// SetApiKeyPermissions replaces every permission row on the given API key +// atomically. Validation that the granted permissions don't exceed the +// creator's capabilities happens in the handler layer. +func (s *RoleService) SetApiKeyPermissions(ctx context.Context, apiKeyID string, grants []models.ApiKeyPermission) error { + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + return s.setApiKeyPermissionsInternal(ctx, tx, apiKeyID, grants) + }) + if err != nil { + return err + } + s.apiKeyCache.Delete(apiKeyID) + return nil +} + +func (s *RoleService) setApiKeyPermissionsInternal(ctx context.Context, tx *gorm.DB, apiKeyID string, grants []models.ApiKeyPermission) error { + for i := range grants { + grants[i].ApiKeyID = apiKeyID + } + if err := tx.WithContext(ctx).Where("api_key_id = ?", apiKeyID).Delete(&models.ApiKeyPermission{}).Error; err != nil { + return fmt.Errorf("failed to clear api key permissions: %w", err) + } + if len(grants) > 0 { + if err := tx.WithContext(ctx).Create(&grants).Error; err != nil { + return fmt.Errorf("failed to insert api key permissions: %w", err) + } + } + return nil +} + +// ---------- OIDC role mappings ---------- + +func (s *RoleService) ListOidcMappings(ctx context.Context) ([]models.OidcRoleMapping, error) { + var out []models.OidcRoleMapping + if err := s.db.WithContext(ctx).Order("claim_value, role_id").Find(&out).Error; err != nil { + return nil, fmt.Errorf("failed to list oidc mappings: %w", err) + } + return out, nil +} + +func (s *RoleService) GetOidcMapping(ctx context.Context, id string) (*models.OidcRoleMapping, error) { + return dbutil.FirstWhere[models.OidcRoleMapping](ctx, s.db.DB, &common.OidcMappingNotFoundError{}, "id = ?", id) +} + +func (s *RoleService) CreateOidcMapping(ctx context.Context, claimValue, roleID string, environmentID *string) (*models.OidcRoleMapping, error) { + if strings.TrimSpace(claimValue) == "" { + return nil, errors.New("claim value is required") + } + if strings.TrimSpace(roleID) == "" { + return nil, errors.New("role id is required") + } + mapping := &models.OidcRoleMapping{ + ClaimValue: claimValue, + RoleID: roleID, + EnvironmentID: environmentID, + Source: models.OidcMappingSourceManual, + } + if err := s.db.WithContext(ctx).Create(mapping).Error; err != nil { + return nil, fmt.Errorf("failed to create oidc mapping: %w", err) + } + return mapping, nil +} + +func (s *RoleService) UpdateOidcMapping(ctx context.Context, id, claimValue, roleID string, environmentID *string) (*models.OidcRoleMapping, error) { + var out models.OidcRoleMapping + err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.OidcRoleMapping + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.OidcMappingNotFoundError{} + } + return fmt.Errorf("failed to load mapping: %w", err) + } + if existing.Source == models.OidcMappingSourceEnv { + return &common.OidcMappingEnvManagedError{} + } + existing.ClaimValue = claimValue + existing.RoleID = roleID + existing.EnvironmentID = environmentID + if err := tx.Save(&existing).Error; err != nil { + return fmt.Errorf("failed to update mapping: %w", err) + } + out = existing + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *RoleService) DeleteOidcMapping(ctx context.Context, id string) error { + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + var existing models.OidcRoleMapping + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.OidcMappingNotFoundError{} + } + return fmt.Errorf("failed to load mapping: %w", err) + } + if existing.Source == models.OidcMappingSourceEnv { + return &common.OidcMappingEnvManagedError{} + } + if err := tx.Delete(&models.OidcRoleMapping{}, "id = ?", id).Error; err != nil { + return fmt.Errorf("failed to delete mapping: %w", err) + } + return nil + }) +} + +// ReconcileEnvOidcMappings replaces every source='env' row in oidc_role_mappings +// with the set declared by `rawSpec` (a JSON array of role.OidcRoleMappingSpec). +// Called once at boot. Behavior is declarative: +// +// - rawSpec empty / unset → leaves DB rows alone (purely UI-managed mode). +// - rawSpec is `[]` → wipes any previously-env-managed rows. +// - rawSpec is a valid JSON array → upserts each spec, deletes stale env rows. +// +// Manual rows (source='manual') are never touched. Bad JSON or an unknown role +// ID returns an error so a misconfigured deployment fails loudly rather than +// silently dropping mappings. +func (s *RoleService) ReconcileEnvOidcMappings(ctx context.Context, rawSpec string) error { + rawSpec = strings.TrimSpace(rawSpec) + if rawSpec == "" { + return nil + } + var specs []roletypes.OidcRoleMappingSpec + if err := json.Unmarshal([]byte(rawSpec), &specs); err != nil { + return fmt.Errorf("invalid OIDC_ROLE_MAPPINGS JSON: %w", err) + } + for i, sp := range specs { + if strings.TrimSpace(sp.ClaimValue) == "" { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: claimValue is required", i) + } + if strings.TrimSpace(sp.RoleID) == "" { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: roleId is required", i) + } + } + + return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + // Verify every referenced role exists. Done inside the tx so a concurrent + // role delete can't race past this check. + for i, sp := range specs { + var count int64 + if err := tx.Model(&models.Role{}).Where("id = ?", sp.RoleID).Count(&count).Error; err != nil { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: failed to verify role: %w", i, err) + } + if count == 0 { + return fmt.Errorf("OIDC_ROLE_MAPPINGS[%d]: role %q does not exist", i, sp.RoleID) + } + } + + // Declarative replace: drop every env-managed row, then insert the new + // set. Manual rows are untouched. + if err := tx.Where("source = ?", models.OidcMappingSourceEnv).Delete(&models.OidcRoleMapping{}).Error; err != nil { + return fmt.Errorf("failed to clear env-managed mappings: %w", err) + } + if len(specs) == 0 { + slog.InfoContext(ctx, "OIDC_ROLE_MAPPINGS reconciled (empty)", "envManagedCount", 0) + return nil + } + rows := make([]models.OidcRoleMapping, len(specs)) + for i, sp := range specs { + rows[i] = models.OidcRoleMapping{ + ClaimValue: sp.ClaimValue, + RoleID: sp.RoleID, + EnvironmentID: sp.EnvironmentID, + Source: models.OidcMappingSourceEnv, + } + } + if err := tx.Create(&rows).Error; err != nil { + return fmt.Errorf("failed to insert env-managed mappings: %w", err) + } + slog.InfoContext(ctx, "OIDC_ROLE_MAPPINGS reconciled", "envManagedCount", len(rows)) + return nil + }) +} + +// ---------- Cache helpers ---------- + +// InvalidateUser drops the cached PermissionSet for one user. Called from +// auth_service after a login that mutates assignments, and from any mutation +// path that doesn't already invalidate explicitly. +func (s *RoleService) InvalidateUser(userID string) { + s.userCache.Delete(userID) +} + +// InvalidateApiKey drops the cached PermissionSet for one API key. +func (s *RoleService) InvalidateApiKey(apiKeyID string) { + s.apiKeyCache.Delete(apiKeyID) +} + +// invalidateUsersAssignedToInternal invalidates every user holding an assignment to +// the given role. Called after a role's permissions change. +func (s *RoleService) invalidateUsersAssignedToInternal(ctx context.Context, roleID string) { + var userIDs []string + if err := s.db.WithContext(ctx). + Model(&models.UserRoleAssignment{}). + Distinct("user_id"). + Where("role_id = ?", roleID). + Pluck("user_id", &userIDs).Error; err != nil { + slog.WarnContext(ctx, "failed to collect users for cache invalidation", "error", err, "role_id", roleID) + return + } + for _, id := range userIDs { + s.userCache.Delete(id) + } +} + +// ---------- helpers ---------- + +func validatePermissionsInternal(perms []string) error { + for _, p := range perms { + if !authz.IsKnownPermission(p) { + return &common.UnknownPermissionError{Perm: p} + } + } + return nil +} + +// ValidatePermissionsAgainstCaller rejects any permission in `desired` that the +// caller does not hold at global scope. Sudo callers (agent / env access +// tokens, bootstrap paths) bypass entirely. Holding a permission only inside a +// specific environment is intentionally insufficient: roles are reusable +// templates that can later be assigned globally, so an env-scoped grant must +// not let the caller mint a global-capable role. +// +// Unknown permission strings are rejected first with an UnknownPermissionError +// so a caller typo-ing a permission gets a descriptive 400 instead of a +// misleading 403 from the escalation guard below (which would always fire on +// an unknown perm because no PermissionSet contains it). This also gives the +// escalation loop a clean invariant: every perm reaching it is real. +// +// Callers should run this before persisting role permissions to defend against +// privilege escalation if the role mutation endpoints are ever exposed beyond +// global admins. +func (s *RoleService) ValidatePermissionsAgainstCaller(caller *authz.PermissionSet, desired []string) error { + if err := validatePermissionsInternal(desired); err != nil { + return err + } + return validatePermissionSetAgainstCallerInternal(caller, desired, "") +} + +// ValidateRoleAssignmentAgainstCaller rejects assigning a role at the requested +// scope when the caller does not hold every permission in that role at that +// same scope. +func (s *RoleService) ValidateRoleAssignmentAgainstCaller(ctx context.Context, caller *authz.PermissionSet, roleID string, environmentID *string) error { + role, err := s.GetRole(ctx, roleID) + if err != nil { + return err + } + + desired := []string(role.Permissions) + if err := validatePermissionsInternal(desired); err != nil { + return err + } + return validatePermissionSetAgainstCallerInternal(caller, desired, pkgutils.DerefString(environmentID)) +} + +func validatePermissionSetAgainstCallerInternal(caller *authz.PermissionSet, desired []string, environmentID string) error { + if caller == nil { + if len(desired) == 0 { + return nil + } + return &common.RolePermissionEscalationError{Perm: desired[0]} + } + if caller.Sudo { + return nil + } + for _, p := range desired { + if !caller.Allows(p, environmentID) { + return &common.RolePermissionEscalationError{Perm: p} + } + } + return nil +} diff --git a/backend/internal/services/role_service_test.go b/backend/internal/services/role_service_test.go new file mode 100644 index 0000000000..8b60f70aae --- /dev/null +++ b/backend/internal/services/role_service_test.go @@ -0,0 +1,104 @@ +package services + +import ( + "context" + "slices" + "testing" + + "github.com/getarcaneapp/arcane/backend/internal/common" + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" + "github.com/stretchr/testify/require" +) + +func TestBackfillPermsForKeyDeduplicatesGlobalAndEnvironmentPermissions(t *testing.T) { + ctx := context.Background() + userSvc, roleSvc := setupUserAndRoleServices(t) + admin := createTestUser(t, userSvc, "admin", "admin") + grantGlobalAdmin(t, roleSvc, admin.ID) + user := createTestUser(t, userSvc, "api-key-owner", "api-key-owner") + envID := "env-1" + createTestEnvironment(t, roleSvc.db, envID, "http://localhost:3552", nil) + + require.NoError(t, roleSvc.SetUserAssignments(ctx, user.ID, []models.UserRoleAssignment{ + {RoleID: authz.BuiltInRoleViewer, EnvironmentID: nil}, + {RoleID: authz.BuiltInRoleEditor, EnvironmentID: &envID}, + })) + + perms, err := roleSvc.backfillPermsForKeyInternal(ctx, roleSvc.db.WithContext(ctx), models.ApiKey{ + UserID: &user.ID, + EnvironmentID: &envID, + }) + require.NoError(t, err) + require.Contains(t, perms, authz.PermContainersList) + require.Equal(t, 1, countPermissionInternal(perms, authz.PermContainersList)) +} + +func countPermissionInternal(perms []string, permission string) int { + return len(slices.DeleteFunc(slices.Clone(perms), func(p string) bool { + return p != permission + })) +} + +func TestValidatePermissionsAgainstCallerRejectsEscalation(t *testing.T) { + _, roleSvc := setupUserAndRoleServices(t) + + caller := authz.NewPermissionSet() + caller.AddGlobal(authz.PermRolesRead, authz.PermRolesList) + + err := roleSvc.ValidatePermissionsAgainstCaller(caller, []string{ + authz.PermRolesRead, + authz.PermUsersDelete, + }) + require.Error(t, err) + require.True(t, common.IsRolePermissionEscalationError(err)) + + require.NoError(t, roleSvc.ValidatePermissionsAgainstCaller(caller, []string{authz.PermRolesRead})) + require.NoError(t, roleSvc.ValidatePermissionsAgainstCaller(authz.SudoPermissionSet(), []string{authz.PermUsersDelete})) +} + +func TestValidatePermissionsAgainstCallerRejectsEnvOnlyGrantForGlobalRole(t *testing.T) { + _, roleSvc := setupUserAndRoleServices(t) + + caller := authz.NewPermissionSet() + caller.AddEnv("env-1", authz.PermContainersStart) + + err := roleSvc.ValidatePermissionsAgainstCaller(caller, []string{authz.PermContainersStart}) + require.Error(t, err) + require.True(t, common.IsRolePermissionEscalationError(err)) +} + +func TestValidatePermissionsAgainstCallerRejectsUnknownPermissionBeforeEscalation(t *testing.T) { + _, roleSvc := setupUserAndRoleServices(t) + + // A sudo caller would otherwise short-circuit past the escalation loop; + // unknown perms must still surface as UnknownPermissionError (→ 400), + // not as an opaque escalation 403 or a silent pass. + err := roleSvc.ValidatePermissionsAgainstCaller(authz.SudoPermissionSet(), []string{"containrs:start"}) + require.Error(t, err) + require.True(t, common.IsUnknownPermissionError(err)) + require.False(t, common.IsRolePermissionEscalationError(err)) +} + +func TestBackfillLegacyRoleAssignmentsIsNoOpWhenColumnAbsent(t *testing.T) { + ctx := context.Background() + _, roleSvc := setupUserAndRoleServices(t) + // setupUserAndRoleServices runs migrations through to current, so + // users.roles never exists in the fresh test schema. + require.False(t, roleSvc.db.Migrator().HasColumn("users", "roles")) + require.NoError(t, roleSvc.BackfillLegacyRoleAssignments(ctx)) + // Idempotent — second call is also fine. + require.NoError(t, roleSvc.BackfillLegacyRoleAssignments(ctx)) +} + +func TestSetUserAssignmentsRejectsUnknownRole(t *testing.T) { + ctx := context.Background() + userSvc, roleSvc := setupUserAndRoleServices(t) + user := createTestUser(t, userSvc, "victim", "victim") + + err := roleSvc.SetUserAssignments(ctx, user.ID, []models.UserRoleAssignment{ + {RoleID: "role_does_not_exist"}, + }) + require.Error(t, err) + require.True(t, common.IsInvalidRoleAssignmentError(err)) +} diff --git a/backend/internal/services/session_service.go b/backend/internal/services/session_service.go index 88ded2334b..ef3d7d39b7 100644 --- a/backend/internal/services/session_service.go +++ b/backend/internal/services/session_service.go @@ -37,6 +37,7 @@ func (s *SessionService) CreateSession(ctx context.Context, userID string, expir RefreshTokenHash: refreshHash, UserAgent: pkgutils.StringPtrFromTrimmed(meta.UserAgent), IPAddress: pkgutils.StringPtrFromTrimmed(meta.IPAddress), + Source: models.UserSessionSourceLocal, LastUsedAt: now, ExpiresAt: expiresAt, } @@ -48,6 +49,26 @@ func (s *SessionService) CreateSession(ctx context.Context, userID string, expir return session, refreshJTI, nil } +func (s *SessionService) CreateFederatedSession(ctx context.Context, userID string, expiresAt time.Time, credentialID string) (*models.UserSession, error) { + refreshHash := hashRefreshJTIInternal(uuid.NewString()) + now := time.Now() + + session := &models.UserSession{ + UserID: userID, + RefreshTokenHash: refreshHash, + Source: models.UserSessionSourceFederated, + FederatedCredentialID: pkgutils.StringPtrFromTrimmed(credentialID), + LastUsedAt: now, + ExpiresAt: expiresAt, + } + + if err := s.db.WithContext(ctx).Create(session).Error; err != nil { + return nil, fmt.Errorf("failed to create federated user session: %w", err) + } + + return session, nil +} + func (s *SessionService) GetSessionByID(ctx context.Context, sessionID string) (*models.UserSession, error) { if strings.TrimSpace(sessionID) == "" { return nil, ErrInvalidToken diff --git a/backend/internal/services/session_service_test.go b/backend/internal/services/session_service_test.go index 31fca49fe3..c4e5e9a5f8 100644 --- a/backend/internal/services/session_service_test.go +++ b/backend/internal/services/session_service_test.go @@ -17,7 +17,6 @@ func TestSessionService_RotateRefreshTokenRequiresCurrentHash(t *testing.T) { require.NoError(t, userSvc.db.Create(&models.User{ BaseModel: models.BaseModel{ID: "u-session"}, Username: "session-user", - Roles: models.StringSlice{"user"}, }).Error) sessionSvc := NewSessionService(db) @@ -41,7 +40,6 @@ func TestSessionService_DeleteExpiredSessions(t *testing.T) { require.NoError(t, userSvc.db.Create(&models.User{ BaseModel: models.BaseModel{ID: "u-cleanup"}, Username: "cleanup-user", - Roles: models.StringSlice{"user"}, }).Error) sessionSvc := NewSessionService(db) diff --git a/backend/internal/services/settings_service.go b/backend/internal/services/settings_service.go index cc0a3b1b5e..e12732c038 100644 --- a/backend/internal/services/settings_service.go +++ b/backend/internal/services/settings_service.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "log/slog" @@ -57,7 +56,7 @@ func NewSettingsService(ctx context.Context, db *database.DB) (*SettingsService, } svc.envOverrides = resolveSettingsEnvOverridesInternal() if len(svc.envOverrides) > 0 { - slog.InfoContext(ctx, "settings env overrides loaded", "count", len(svc.envOverrides)) + slog.InfoContext(ctx, "Loaded Environment Settings Overrides", "count", len(svc.envOverrides)) } err := svc.LoadDatabaseSettings(ctx) @@ -121,16 +120,12 @@ func DefaultSettingsConfig() *models.Settings { DockerClientRefreshInterval: models.SettingVariable{Value: "*/30 * * * * *"}, EventCleanupInterval: models.SettingVariable{Value: "0 0 */6 * * *"}, ExpiredSessionsCleanupInterval: models.SettingVariable{Value: "0 0 0 * * *"}, + ActivityHistoryRetentionDays: models.SettingVariable{Value: "30"}, + ActivityHistoryMaxEntries: models.SettingVariable{Value: "1000"}, AutoInjectEnv: models.SettingVariable{Value: "false"}, - PruneMode: models.SettingVariable{Value: "dangling"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. DefaultDeployPullPolicy: models.SettingVariable{Value: "missing"}, ScheduledPruneEnabled: models.SettingVariable{Value: "false"}, ScheduledPruneInterval: models.SettingVariable{Value: "0 0 0 * * *"}, - ScheduledPruneContainers: models.SettingVariable{Value: "true"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneImages: models.SettingVariable{Value: "true"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneVolumes: models.SettingVariable{Value: "false"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneNetworks: models.SettingVariable{Value: "true"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. - ScheduledPruneBuildCache: models.SettingVariable{Value: "false"}, //nolint:staticcheck // Legacy prune setting is still seeded for migration compatibility. PruneContainerMode: models.SettingVariable{Value: "stopped"}, PruneContainerUntil: models.SettingVariable{Value: ""}, PruneImageMode: models.SettingVariable{Value: "dangling"}, @@ -165,37 +160,36 @@ func DefaultSettingsConfig() *models.Settings { TrivyCpuLimit: models.SettingVariable{Value: "1"}, TrivyMemoryLimitMb: models.SettingVariable{Value: "0"}, TrivyConcurrentScanContainers: models.SettingVariable{Value: "1"}, - // AuthOidcConfig DEPRECATED will be removed in a future release - AuthOidcConfig: models.SettingVariable{Value: "{}"}, - OidcEnabled: models.SettingVariable{Value: "false"}, - OidcClientId: models.SettingVariable{Value: ""}, - OidcClientSecret: models.SettingVariable{Value: ""}, - OidcIssuerUrl: models.SettingVariable{Value: ""}, - OidcAuthorizationEndpoint: models.SettingVariable{Value: ""}, - OidcTokenEndpoint: models.SettingVariable{Value: ""}, - OidcUserinfoEndpoint: models.SettingVariable{Value: ""}, - OidcJwksEndpoint: models.SettingVariable{Value: ""}, - OidcScopes: models.SettingVariable{Value: "openid email profile"}, - OidcAdminClaim: models.SettingVariable{Value: ""}, - OidcAdminValue: models.SettingVariable{Value: ""}, - OidcSkipTlsVerify: models.SettingVariable{Value: "false"}, - OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"}, - OidcMergeAccounts: models.SettingVariable{Value: "false"}, - OidcProviderName: models.SettingVariable{Value: ""}, - OidcProviderLogoUrl: models.SettingVariable{Value: ""}, - OidcMobileRedirectUris: models.SettingVariable{Value: "arcane-mobile://oidc-callback"}, - MobileNavigationMode: models.SettingVariable{Value: "floating"}, - MobileNavigationShowLabels: models.SettingVariable{Value: "true"}, - SidebarHoverExpansion: models.SettingVariable{Value: "true"}, - KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"}, - ApplicationTheme: models.SettingVariable{Value: "default"}, - AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"}, - OledMode: models.SettingVariable{Value: "false"}, - MaxImageUploadSize: models.SettingVariable{Value: "500"}, - GitSyncMaxFiles: models.SettingVariable{Value: "500"}, - GitSyncMaxTotalSizeMb: models.SettingVariable{Value: "50"}, - GitSyncMaxBinarySizeMb: models.SettingVariable{Value: "10"}, - EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"}, + LifecycleEnabled: models.SettingVariable{Value: "false"}, + LifecycleMaxTimeoutSec: models.SettingVariable{Value: "300"}, + OidcEnabled: models.SettingVariable{Value: "false"}, + OidcClientId: models.SettingVariable{Value: ""}, + OidcClientSecret: models.SettingVariable{Value: ""}, + OidcIssuerUrl: models.SettingVariable{Value: ""}, + OidcAuthorizationEndpoint: models.SettingVariable{Value: ""}, + OidcTokenEndpoint: models.SettingVariable{Value: ""}, + OidcUserinfoEndpoint: models.SettingVariable{Value: ""}, + OidcJwksEndpoint: models.SettingVariable{Value: ""}, + OidcScopes: models.SettingVariable{Value: "openid email profile"}, + OidcGroupsClaim: models.SettingVariable{Value: "groups"}, + OidcSkipTlsVerify: models.SettingVariable{Value: "false"}, + OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"}, + OidcMergeAccounts: models.SettingVariable{Value: "false"}, + OidcProviderName: models.SettingVariable{Value: ""}, + OidcProviderLogoUrl: models.SettingVariable{Value: ""}, + OidcMobileRedirectUris: models.SettingVariable{Value: "arcane-mobile://oidc-callback"}, + MobileNavigationMode: models.SettingVariable{Value: "floating"}, + MobileNavigationShowLabels: models.SettingVariable{Value: "true"}, + SidebarHoverExpansion: models.SettingVariable{Value: "true"}, + KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"}, + ApplicationTheme: models.SettingVariable{Value: "default"}, + AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"}, + OledMode: models.SettingVariable{Value: "false"}, + MaxImageUploadSize: models.SettingVariable{Value: "500"}, + GitSyncMaxFiles: models.SettingVariable{Value: "500"}, + GitSyncMaxTotalSizeMb: models.SettingVariable{Value: "50"}, + GitSyncMaxBinarySizeMb: models.SettingVariable{Value: "10"}, + EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"}, DockerAPITimeout: models.SettingVariable{Value: "30"}, DockerImagePullTimeout: models.SettingVariable{Value: "600"}, @@ -231,22 +225,6 @@ func (s *SettingsService) loadDatabaseSettingsInternal(ctx context.Context, db * return nil, fmt.Errorf("failed to load configuration from the database: %w", err) } - updated, err := s.ensureGranularPruneSettingsMigratedInternal(ctx, db, loaded) //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - if err != nil { - return nil, err - } - if updated { - loaded = nil - queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second) - defer queryCancel() - err = db. - WithContext(queryCtx). - Find(&loaded).Error - if err != nil { - return nil, fmt.Errorf("failed to reload configuration from the database after prune migration: %w", err) - } - } - for _, v := range loaded { err = dest.UpdateField(v.Key, v.Value, false) @@ -261,144 +239,6 @@ func (s *SettingsService) loadDatabaseSettingsInternal(ctx context.Context, db * return dest, nil } -// Deprecated: This migration path exists only to lift legacy prune settings into the granular model. -func (s *SettingsService) ensureGranularPruneSettingsMigratedInternal(ctx context.Context, db *database.DB, loaded []models.SettingVariable) (bool, error) { - loadedMap := make(map[string]string, len(loaded)) - for _, setting := range loaded { - loadedMap[setting.Key] = setting.Value - } - - missingKeys := []string{ - "pruneContainerMode", - "pruneContainerUntil", - "pruneImageMode", - "pruneImageUntil", - "pruneVolumeMode", - "pruneNetworkMode", - "pruneNetworkUntil", - "pruneBuildCacheMode", - "pruneBuildCacheUntil", - } - - needsMigration := false - for _, key := range missingKeys { - if _, ok := loadedMap[key]; !ok { - needsMigration = true - break - } - } - - if !needsMigration { - return false, nil - } - - pruneMode := loadedMap["dockerPruneMode"] - if pruneMode == "" { - pruneMode = "dangling" - } - - valuesToPersist := []models.SettingVariable{ - {Key: "pruneContainerMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyContainerPruneModeInternal(loadedMap["scheduledPruneContainers"]), "pruneContainerMode", "scheduledPruneContainerMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneContainerUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneContainerUntil", "scheduledPruneContainerUntil")}, - {Key: "pruneImageMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyImagePruneModeInternal(pruneMode, loadedMap["scheduledPruneImages"]), "pruneImageMode", "scheduledPruneImageMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneImageUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneImageUntil", "scheduledPruneImageUntil")}, - {Key: "pruneVolumeMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyVolumePruneModeInternal(pruneMode, loadedMap["scheduledPruneVolumes"]), "pruneVolumeMode", "scheduledPruneVolumeMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneNetworkMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyNetworkPruneModeInternal(loadedMap["scheduledPruneNetworks"]), "pruneNetworkMode", "scheduledPruneNetworkMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneNetworkUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneNetworkUntil", "scheduledPruneNetworkUntil")}, - {Key: "pruneBuildCacheMode", Value: coalesceSettingValueInternal(loadedMap, migrateLegacyBuildCachePruneModeInternal(pruneMode, loadedMap["scheduledPruneBuildCache"]), "pruneBuildCacheMode", "scheduledPruneBuildCacheMode")}, //nolint:staticcheck // Legacy prune migration remains temporarily for backward compatibility. - {Key: "pruneBuildCacheUntil", Value: coalesceSettingValueInternal(loadedMap, "", "pruneBuildCacheUntil", "scheduledPruneBuildCacheUntil")}, - } - - if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for _, value := range valuesToPersist { - if _, ok := loadedMap[value.Key]; ok { - continue - } - if err := tx.Save(&value).Error; err != nil { - return fmt.Errorf("failed to persist migrated prune setting %s: %w", value.Key, err) - } - } - return nil - }); err != nil { - return false, fmt.Errorf("failed to migrate granular prune settings: %w", err) - } - - slog.InfoContext(ctx, "migrated legacy prune settings to granular prune settings") - return true, nil -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyContainerPruneModeInternal(legacyValue string) string { - if isLegacyPruneEnabledInternal(legacyValue, true) { - return "stopped" - } - return "none" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyImagePruneModeInternal(pruneMode, legacyValue string) string { - if !isLegacyPruneEnabledInternal(legacyValue, true) { - return "none" - } - if pruneMode == "all" { - return "all" - } - return "dangling" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyVolumePruneModeInternal(pruneMode, legacyValue string) string { - if !isLegacyPruneEnabledInternal(legacyValue, false) { - return "none" - } - if pruneMode == "all" { - return "all" - } - return "anonymous" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyNetworkPruneModeInternal(legacyValue string) string { - if isLegacyPruneEnabledInternal(legacyValue, true) { - return "unused" - } - return "none" -} - -// Deprecated: This migration helper exists only to translate legacy prune settings. -func migrateLegacyBuildCachePruneModeInternal(pruneMode, legacyValue string) string { - if !isLegacyPruneEnabledInternal(legacyValue, false) { - return "none" - } - if pruneMode == "all" { - return "all" - } - return "unused" -} - -func isLegacyPruneEnabledInternal(value string, defaultValue bool) bool { - if strings.TrimSpace(value) == "" { - return defaultValue - } - - parsed, err := strconv.ParseBool(value) - if err != nil { - return defaultValue - } - - return parsed -} - -func coalesceSettingValueInternal(loadedMap map[string]string, fallback string, keys ...string) string { - for _, key := range keys { - if value, ok := loadedMap[key]; ok { - return value - } - } - - return fallback -} - func (s *SettingsService) loadDatabaseConfigFromEnv(ctx context.Context, db *database.DB) (*models.Settings, error) { dest := s.getDefaultSettings() @@ -439,14 +279,14 @@ func (s *SettingsService) loadDatabaseConfigFromEnv(ctx context.Context, db *dat slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: env override found", "key", key, "env", envVarName, "valueMasked", mask) rv.Field(i).FieldByName("Value").SetString(utils.TrimQuotes(val)) continue - } else if val, ok := settingsMap[key]; ok { + } + if val, ok := settingsMap[key]; ok { // Fallback to database if environment variable is not set slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: using database fallback", "key", key) rv.Field(i).FieldByName("Value").SetString(val) continue - } else { - slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: env not set and no database value", "key", key, "env", envVarName) } + slog.DebugContext(ctx, "loadDatabaseConfigFromEnv: env not set and no database value", "key", key, "env", envVarName) } // debug: final snapshot (only show which fields are non-empty) @@ -543,113 +383,6 @@ func (s *SettingsService) getEffectiveSettingsConfigInternal(ctx context.Context return settings } -// MigrateOidcConfigToFields migrates the legacy JSON authOidcConfig to individual fields, -// and renames legacy auth* keys to their new oidc* names. -// This should be called during bootstrap to ensure existing configurations are preserved. -func (s *SettingsService) MigrateOidcConfigToFields(ctx context.Context) error { - currentSettings, err := s.GetSettings(ctx) - if err != nil { - return fmt.Errorf("failed to get settings for OIDC migration: %w", err) - } - - // Migrate legacy key names (authOidcEnabled -> oidcEnabled, authOidcMergeAccounts -> oidcMergeAccounts) - if err := s.migrateOidcKeyNames(ctx); err != nil { - slog.WarnContext(ctx, "Failed to migrate OIDC key names", "error", err) - // Continue with JSON migration even if key rename fails - } - - // Check if migration is needed: if we have authOidcConfig but no oidcClientId - if currentSettings.AuthOidcConfig.Value == "" || currentSettings.AuthOidcConfig.Value == "{}" { - slog.DebugContext(ctx, "No OIDC config to migrate") - return nil - } - - // If individual fields are already populated, skip migration - if currentSettings.OidcClientId.Value != "" { - slog.DebugContext(ctx, "OIDC fields already populated, skipping migration") - return nil - } - - var oidcConfig models.OidcConfig - if err := json.Unmarshal([]byte(currentSettings.AuthOidcConfig.Value), &oidcConfig); err != nil { - slog.WarnContext(ctx, "Failed to parse legacy OIDC config for migration", "error", err) - return nil - } - - // Only migrate if there's actual data - if oidcConfig.ClientID == "" && oidcConfig.IssuerURL == "" { - slog.DebugContext(ctx, "Legacy OIDC config is empty, skipping migration") - return nil - } - - slog.InfoContext(ctx, "Migrating legacy OIDC config to individual fields") - - scopes := oidcConfig.Scopes - if scopes == "" { - scopes = "openid email profile" - } - - _, err = s.UpdateSettings(ctx, settings.Update{ - OidcClientId: new(oidcConfig.ClientID), - OidcClientSecret: new(oidcConfig.ClientSecret), - OidcIssuerUrl: new(oidcConfig.IssuerURL), - OidcScopes: new(scopes), - OidcAdminClaim: new(oidcConfig.AdminClaim), - OidcAdminValue: new(oidcConfig.AdminValue), - }) - if err != nil { - return fmt.Errorf("failed to migrate OIDC config: %w", err) - } - - slog.InfoContext(ctx, "Successfully migrated OIDC config to individual fields") - return nil -} - -// migrateOidcKeyNames renames legacy authOidc* keys to new oidc* keys in the database. -func (s *SettingsService) migrateOidcKeyNames(ctx context.Context) error { - keyMappings := map[string]string{ - "authOidcEnabled": "oidcEnabled", - "authOidcMergeAccounts": "oidcMergeAccounts", - "authOidcClientId": "oidcClientId", - "authOidcClientSecret": "oidcClientSecret", - "authOidcIssuerUrl": "oidcIssuerUrl", - "authOidcScopes": "oidcScopes", - "authOidcAdminClaim": "oidcAdminClaim", - "authOidcAdminValue": "oidcAdminValue", - } - - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for oldKey, newKey := range keyMappings { - // Check if old key exists - var oldSetting models.SettingVariable - if err := tx.Where("key = ?", oldKey).First(&oldSetting).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue // Old key doesn't exist, nothing to migrate - } - return fmt.Errorf("failed to check old key %s: %w", oldKey, err) - } - - // Check if new key already exists - var newSetting models.SettingVariable - if err := tx.Where("key = ?", newKey).First(&newSetting).Error; err == nil { - // New key already exists, delete the old one - if err := tx.Delete(&oldSetting).Error; err != nil { - return fmt.Errorf("failed to delete old key %s: %w", oldKey, err) - } - slog.DebugContext(ctx, "Deleted duplicate legacy key", "oldKey", oldKey, "newKey", newKey) - continue - } - - // Rename: update key from old to new - if err := tx.Model(&oldSetting).Update("key", newKey).Error; err != nil { - return fmt.Errorf("failed to rename key %s to %s: %w", oldKey, newKey, err) - } - slog.InfoContext(ctx, "Migrated OIDC setting key", "oldKey", oldKey, "newKey", newKey) - } - return nil - }) -} - func (s *SettingsService) UpdateSetting(ctx context.Context, key, value string) error { if err := s.updateSettingValueNoRefreshInternal(ctx, key, value); err != nil { return err @@ -735,7 +468,7 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa changedAutoHeal := false changedTimeouts := make([]libarcane.SettingUpdate, 0) - for i := 0; i < rt.NumField(); i++ { + for i := range rt.NumField() { field := rt.Field(i) fieldValue := rv.Field(i) @@ -768,7 +501,7 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa } if key == "accentColor" && value != "" && value != "default" && !settings.SafeAccentColor.MatchString(value) { - return nil, false, false, false, false, false, nil, fmt.Errorf("invalid accentColor value") + return nil, false, false, false, false, false, nil, errors.New("invalid accentColor value") } var valueToSave string @@ -798,12 +531,7 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa case "autoUpdate", "autoUpdateInterval": changedAutoUpdate = true case "scheduledPruneEnabled", - "scheduledPruneInterval", - "scheduledPruneContainers", - "scheduledPruneImages", - "scheduledPruneVolumes", - "scheduledPruneNetworks", - "scheduledPruneBuildCache": + "scheduledPruneInterval": changedScheduledPrune = true case "vulnerabilityScanEnabled", "vulnerabilityScanInterval", "trivyNetwork", "trivySecurityOpts", "trivyPrivileged", "trivyResourceLimitsEnabled", "trivyCpuLimit", "trivyMemoryLimitMb", "trivyConcurrentScanContainers": changedVulnerabilityScan = true @@ -849,38 +577,6 @@ func (s *SettingsService) persistSettings(ctx context.Context, values []models.S } func (s *SettingsService) handleOidcConfigUpdate(ctx context.Context, updates settings.Update) error { - // Handle legacy JSON config format (for backward compatibility during migration) - if updates.AuthOidcConfig != nil { - newCfgStr := *updates.AuthOidcConfig - var incoming models.OidcConfig - if err := json.Unmarshal([]byte(newCfgStr), &incoming); err != nil { - return fmt.Errorf("invalid authOidcConfig JSON: %w", err) - } - - current, err := s.GetSettings(ctx) - if err != nil { - return fmt.Errorf("failed to load current settings: %w", err) - } - - if current.AuthOidcConfig.Value != "" { - var existing models.OidcConfig - if err := json.Unmarshal([]byte(current.AuthOidcConfig.Value), &existing); err == nil { - if incoming.ClientSecret == "" { - incoming.ClientSecret = existing.ClientSecret - } - } - } - - mergedBytes, err := incoming.MarshalDocument() - if err != nil { - return fmt.Errorf("failed to marshal merged OIDC config: %w", err) - } - - if err := s.updateSettingValueNoRefreshInternal(ctx, "authOidcConfig", string(mergedBytes)); err != nil { - return fmt.Errorf("failed to update authOidcConfig: %w", err) - } - } - // Handle new individual field for client secret (sensitive field) if updates.OidcClientSecret != nil { secret := *updates.OidcClientSecret @@ -1134,11 +830,11 @@ func (s *SettingsService) GetStringSetting(ctx context.Context, key, defaultValu } func (s *SettingsService) SetBoolSetting(ctx context.Context, key string, value bool) error { - return s.UpdateSetting(ctx, key, fmt.Sprintf("%t", value)) + return s.UpdateSetting(ctx, key, strconv.FormatBool(value)) } func (s *SettingsService) SetIntSetting(ctx context.Context, key string, value int) error { - return s.UpdateSetting(ctx, key, fmt.Sprintf("%d", value)) + return s.UpdateSetting(ctx, key, strconv.Itoa(value)) } func (s *SettingsService) SetStringSetting(ctx context.Context, key, value string) error { diff --git a/backend/internal/services/settings_service_test.go b/backend/internal/services/settings_service_test.go index 75f71fae3a..13d81fc2ea 100644 --- a/backend/internal/services/settings_service_test.go +++ b/backend/internal/services/settings_service_test.go @@ -2,7 +2,6 @@ package services import ( "context" - "encoding/json" "path/filepath" "testing" @@ -129,80 +128,6 @@ func TestSettingsService_GetSettings_UsesCachedSnapshotWithoutDatabase(t *testin require.Equal(t, "http://cached", settings.BaseServerURL.Value) } -func TestSettingsService_LoadDatabaseSettings_MigratesLegacyPruneSettings(t *testing.T) { - ctx := context.Background() - t.Setenv("UI_CONFIGURATION_DISABLED", "false") - t.Setenv("AGENT_MODE", "false") - t.Setenv("EDGE_AGENT", "false") - - tests := []struct { - name string - legacy []models.SettingVariable - assertCfg func(t *testing.T, cfg *models.Settings) - }{ - { - name: "maps all prune mode to aggressive resource modes", - legacy: []models.SettingVariable{ - {Key: "dockerPruneMode", Value: "all"}, - {Key: "scheduledPruneContainers", Value: "true"}, - {Key: "scheduledPruneImages", Value: "true"}, - {Key: "scheduledPruneVolumes", Value: "true"}, - {Key: "scheduledPruneNetworks", Value: "true"}, - {Key: "scheduledPruneBuildCache", Value: "true"}, - }, - assertCfg: func(t *testing.T, cfg *models.Settings) { - require.Equal(t, "stopped", cfg.PruneContainerMode.Value) - require.Equal(t, "all", cfg.PruneImageMode.Value) - require.Equal(t, "all", cfg.PruneVolumeMode.Value) - require.Equal(t, "unused", cfg.PruneNetworkMode.Value) - require.Equal(t, "all", cfg.PruneBuildCacheMode.Value) - }, - }, - { - name: "uses dangling defaults when legacy booleans are missing", - legacy: []models.SettingVariable{ - {Key: "dockerPruneMode", Value: "dangling"}, - }, - assertCfg: func(t *testing.T, cfg *models.Settings) { - require.Equal(t, "stopped", cfg.PruneContainerMode.Value) - require.Equal(t, "dangling", cfg.PruneImageMode.Value) - require.Equal(t, "none", cfg.PruneVolumeMode.Value) - require.Equal(t, "unused", cfg.PruneNetworkMode.Value) - require.Equal(t, "none", cfg.PruneBuildCacheMode.Value) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db := setupSettingsTestDB(t) - require.NoError(t, db.DB.Create(&tt.legacy).Error) - - svc := &SettingsService{db: db} - cfg, err := svc.loadDatabaseSettingsInternal(ctx, db) - require.NoError(t, err) - - tt.assertCfg(t, cfg) - - for _, key := range []string{ - "pruneContainerMode", - "pruneContainerUntil", - "pruneImageMode", - "pruneImageUntil", - "pruneVolumeMode", - "pruneNetworkMode", - "pruneNetworkUntil", - "pruneBuildCacheMode", - "pruneBuildCacheUntil", - } { - var setting models.SettingVariable - err := db.DB.Where("key = ?", key).First(&setting).Error - require.NoErrorf(t, err, "expected migrated key %s to be persisted", key) - } - }) - } -} - func TestSettingsService_PruneUnknownSettings_RemovesStaleKeys(t *testing.T) { ctx := context.Background() db := setupSettingsTestDB(t) @@ -412,11 +337,9 @@ func TestSettingsService_UpdateSettings_PruneModesDoNotTriggerScheduledPruneCall callbackCalls++ } - imageMode := "all" - containerUntil := "24h" _, err = svc.UpdateSettings(ctx, settings.Update{ - PruneImageMode: &imageMode, - PruneContainerUntil: &containerUntil, + PruneImageMode: new("all"), + PruneContainerUntil: new("24h"), }) require.NoError(t, err) require.Equal(t, 0, callbackCalls) @@ -433,9 +356,8 @@ func TestSettingsService_UpdateSettings_ScheduledPruneScheduleTriggersCallback(t callbackCalls++ } - enabled := "true" _, err = svc.UpdateSettings(ctx, settings.Update{ - ScheduledPruneEnabled: &enabled, + ScheduledPruneEnabled: new("true"), }) require.NoError(t, err) require.Equal(t, 1, callbackCalls) @@ -489,44 +411,6 @@ func TestSettingsService_EnsureEncryptionKey(t *testing.T) { require.Equal(t, k1, sv.Value) } -func TestSettingsService_UpdateSettings_MergeOidcSecret(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - - // Seed existing OIDC config with a secret - existing := models.OidcConfig{ - ClientID: "old", - ClientSecret: "keep-this", - IssuerURL: "https://issuer", - } - b, err := json.Marshal(existing) - require.NoError(t, err) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", string(b))) - - // Incoming update missing clientSecret should preserve existing one - incoming := models.OidcConfig{ - ClientID: "new", - IssuerURL: "https://issuer", - } - nb, err := json.Marshal(incoming) - require.NoError(t, err) - updates := settings.Update{ - AuthOidcConfig: new(string(nb)), - } - _, err = svc.UpdateSettings(ctx, updates) - require.NoError(t, err) - - var cfgVar models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "authOidcConfig").First(&cfgVar).Error) - - var merged models.OidcConfig - require.NoError(t, json.Unmarshal([]byte(cfgVar.Value), &merged)) - require.Equal(t, "new", merged.ClientID) - require.Equal(t, "keep-this", merged.ClientSecret) -} - func TestSettingsService_LoadDatabaseSettings_ReloadsChanges(t *testing.T) { ctx := context.Background() db := setupSettingsTestDB(t) @@ -833,183 +717,6 @@ func TestSettingsService_LoadDatabaseSettings_InternalKeys_EnvMode(t *testing.T) require.Equal(t, internalVal, cfg.InstanceID.Value) } -func TestSettingsService_MigrateOidcConfigToFields(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Seed legacy OIDC JSON config - legacyConfig := models.OidcConfig{ - ClientID: "legacy-client-id", - ClientSecret: "legacy-secret", - IssuerURL: "https://legacy-issuer.example", - Scopes: "openid email profile", - AdminClaim: "groups", - AdminValue: "admin", - } - b, err := json.Marshal(legacyConfig) - require.NoError(t, err) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", string(b))) - - // Run migration - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify individual fields were populated - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Equal(t, "legacy-client-id", clientId.Value) - - var clientSecret models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientSecret").First(&clientSecret).Error) - require.Equal(t, "legacy-secret", clientSecret.Value) - - var issuerUrl models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcIssuerUrl").First(&issuerUrl).Error) - require.Equal(t, "https://legacy-issuer.example", issuerUrl.Value) - - var scopes models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcScopes").First(&scopes).Error) - require.Equal(t, "openid email profile", scopes.Value) - - var adminClaim models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminClaim").First(&adminClaim).Error) - require.Equal(t, "groups", adminClaim.Value) - - var adminValue models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminValue").First(&adminValue).Error) - require.Equal(t, "admin", adminValue.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_SkipsIfAlreadyMigrated(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Pre-populate individual field - require.NoError(t, svc.UpdateSetting(ctx, "oidcClientId", "already-migrated")) - - // Seed legacy config too - legacyConfig := models.OidcConfig{ - ClientID: "old-id", - IssuerURL: "https://old-issuer.example", - } - b, err := json.Marshal(legacyConfig) - require.NoError(t, err) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", string(b))) - - // Run migration - should skip since individual field is populated - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify field was NOT overwritten - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Equal(t, "already-migrated", clientId.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_RealWorldJSON(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Test with real-world JSON format (as stored in database) - realWorldJSON := `{"clientId":"ab92b6cf-283d-4764-9308-92a9b9496bf1","clientSecret":"super-secret-value","issuerUrl":"https://id.ofkm.us","scopes":"openid email profile groups","adminClaim":"groups","adminValue":"_arcane_admins"}` - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", realWorldJSON)) - - // Run migration - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify all individual fields were populated correctly - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Equal(t, "ab92b6cf-283d-4764-9308-92a9b9496bf1", clientId.Value) - - var clientSecret models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientSecret").First(&clientSecret).Error) - require.Equal(t, "super-secret-value", clientSecret.Value) - - var issuerUrl models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcIssuerUrl").First(&issuerUrl).Error) - require.Equal(t, "https://id.ofkm.us", issuerUrl.Value) - - var scopes models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcScopes").First(&scopes).Error) - require.Equal(t, "openid email profile groups", scopes.Value) - - var adminClaim models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminClaim").First(&adminClaim).Error) - require.Equal(t, "groups", adminClaim.Value) - - var adminValue models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcAdminValue").First(&adminValue).Error) - require.Equal(t, "_arcane_admins", adminValue.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_EmptyConfig(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Empty config should not cause errors - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", "{}")) - - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - // Verify fields remain empty - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Empty(t, clientId.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_InvalidJSON(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Invalid JSON should not cause errors (gracefully handled) - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", "not valid json")) - - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) // Should not return error, just skip - - // Verify fields remain empty - var clientId models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcClientId").First(&clientId).Error) - require.Empty(t, clientId.Value) -} - -func TestSettingsService_MigrateOidcConfigToFields_DefaultScopes(t *testing.T) { - ctx := context.Background() - db := setupSettingsTestDB(t) - svc, err := NewSettingsService(ctx, db) - require.NoError(t, err) - require.NoError(t, svc.EnsureDefaultSettings(ctx)) - - // Config without scopes should get default scopes - configWithoutScopes := `{"clientId":"test-client","issuerUrl":"https://test.example"}` - require.NoError(t, svc.UpdateSetting(ctx, "authOidcConfig", configWithoutScopes)) - - err = svc.MigrateOidcConfigToFields(ctx) - require.NoError(t, err) - - var scopes models.SettingVariable - require.NoError(t, svc.db.WithContext(ctx).Where("key = ?", "oidcScopes").First(&scopes).Error) - require.Equal(t, "openid email profile", scopes.Value) -} - func TestSettingsService_NormalizeProjectsDirectory_ConvertsRelativeToAbsolute(t *testing.T) { ctx := context.Background() db := setupSettingsTestDB(t) diff --git a/backend/internal/services/swarm_service.go b/backend/internal/services/swarm_service.go index 777edf1720..8e1c683e82 100644 --- a/backend/internal/services/swarm_service.go +++ b/backend/internal/services/swarm_service.go @@ -454,10 +454,10 @@ func (s *SwarmService) StreamServiceLogs(ctx context.Context, serviceID string, defer func() { _ = logs.Close() }() if follow { - return streamMultiplexedLogs(ctx, logs, logsChan) + return dockerutil.StreamMultiplexedLogs(ctx, logs, logsChan) } - return readAllLogs(logs, logsChan) + return dockerutil.ReadAllLogs(ctx, logs, logsChan) } func (s *SwarmService) ListNodesPaginated(ctx context.Context, environmentID string, params pagination.QueryParams) ([]swarmtypes.NodeSummary, pagination.Response, error) { @@ -648,7 +648,7 @@ func (s *SwarmService) fetchSwarmNodeIdentityViaEdgeInternal(ctx context.Context return nil, err } if !parsed.Success { - return nil, fmt.Errorf("swarm node identity probe failed") + return nil, errors.New("swarm node identity probe failed") } return &parsed.Data, nil @@ -841,10 +841,7 @@ func (s *SwarmService) DeployStack(ctx context.Context, environmentID string, re workingDir = stackSourceDir } - pm, err := s.getPathMapperInternal(ctx) - if err != nil { - slog.WarnContext(ctx, "failed to initialize path mapper, deploying without path translation", "error", err) - } + pm := s.getPathMapperInternal(ctx) if err := libswarm.DeployStack(ctx, dockerClient, libswarm.StackDeployOptions{ Name: stackName, @@ -1652,10 +1649,7 @@ func (s *SwarmService) RenderStackConfig(ctx context.Context, req swarmtypes.Sta return nil, err } - pm, err := s.getPathMapperInternal(ctx) - if err != nil { - slog.WarnContext(ctx, "failed to initialize path mapper, deploying without path translation", "error", err) - } + pm := s.getPathMapperInternal(ctx) result, err := libswarm.RenderStackConfig(ctx, libswarm.StackRenderOptions{ Name: req.Name, @@ -2241,7 +2235,7 @@ func (s *SwarmService) resolveSwarmStackSourceEnvironmentDirInternal(ctx context return rootDir, environmentDir, nil } -func (s *SwarmService) getPathMapperInternal(ctx context.Context) (*appfs.PathMapper, error) { +func (s *SwarmService) getPathMapperInternal(ctx context.Context) *appfs.PathMapper { configuredPath := s.settingsService.GetStringSetting(ctx, "swarmStackSourcesDirectory", defaultSwarmStackSourceRootDir) var containerDir, hostDir string @@ -2284,10 +2278,10 @@ func (s *SwarmService) getPathMapperInternal(ctx context.Context) (*appfs.PathMa pm := appfs.NewPathMapper(containerDirResolved, hostDirResolved) if !pm.IsNonMatchingMount() { - return nil, nil + return nil } - return pm, nil + return pm } func (s *SwarmService) ensureSwarmManagerInternal(ctx context.Context) error { @@ -2449,7 +2443,7 @@ func (s *SwarmService) buildStackPaginationConfigInternal() pagination.Config[sw func buildPaginationResponseInternal[T any](result pagination.FilterResult[T], params pagination.QueryParams) pagination.Response { totalPages := int64(0) if params.Limit > 0 { - totalPages = (int64(result.TotalCount) + int64(params.Limit) - 1) / int64(params.Limit) + totalPages = (result.TotalCount + int64(params.Limit) - 1) / int64(params.Limit) } page := 1 @@ -2459,10 +2453,10 @@ func buildPaginationResponseInternal[T any](result pagination.FilterResult[T], p return pagination.Response{ TotalPages: totalPages, - TotalItems: int64(result.TotalCount), + TotalItems: result.TotalCount, CurrentPage: page, ItemsPerPage: params.Limit, - GrandTotalItems: int64(result.TotalAvailable), + GrandTotalItems: result.TotalAvailable, } } diff --git a/backend/internal/services/swarm_service_test.go b/backend/internal/services/swarm_service_test.go index 78cf85ffb1..245bf91660 100644 --- a/backend/internal/services/swarm_service_test.go +++ b/backend/internal/services/swarm_service_test.go @@ -129,8 +129,7 @@ func TestSwarmService_getPathMapperInternal(t *testing.T) { svc := NewSwarmService(nil, settingsSvc, nil, nil, nil) t.Run("returns nil when paths match", func(t *testing.T) { - pm, err := svc.getPathMapperInternal(ctx) - require.NoError(t, err) + pm := svc.getPathMapperInternal(ctx) require.Nil(t, pm) // Default is /app/data/swarm/sources which matches itself }) @@ -140,8 +139,7 @@ func TestSwarmService_getPathMapperInternal(t *testing.T) { err := settingsSvc.UpdateSetting(ctx, "swarmStackSourcesDirectory", containerDir+":"+hostDir) require.NoError(t, err) - pm, err := svc.getPathMapperInternal(ctx) - require.NoError(t, err) + pm := svc.getPathMapperInternal(ctx) require.NotNil(t, pm) require.True(t, pm.IsNonMatchingMount()) diff --git a/backend/internal/services/system_service.go b/backend/internal/services/system_service.go index 9ab5e957e6..bbf0874e56 100644 --- a/backend/internal/services/system_service.go +++ b/backend/internal/services/system_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "log/slog" "regexp" @@ -10,8 +11,10 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/dockerrun" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" + "github.com/getarcaneapp/arcane/backend/pkg/utils" containertypes "github.com/getarcaneapp/arcane/types/container" "github.com/getarcaneapp/arcane/types/system" "github.com/goccy/go-yaml" @@ -28,6 +31,9 @@ type SystemService struct { volumeService *VolumeService networkService *NetworkService settingsService *SettingsService + activityService *ActivityService + pruneMu sync.Mutex + runningPrunes map[string]string } func NewSystemService( @@ -38,6 +44,7 @@ func NewSystemService( volumeService *VolumeService, networkService *NetworkService, settingsService *SettingsService, + activityService *ActivityService, ) *SystemService { return &SystemService{ db: db, @@ -47,6 +54,8 @@ func NewSystemService( volumeService: volumeService, networkService: networkService, settingsService: settingsService, + activityService: activityService, + runningPrunes: make(map[string]string), } } @@ -54,7 +63,7 @@ var systemUser = models.User{ Username: "System", } -func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest) (*system.PruneAllResult, error) { +func (s *SystemService) PruneAll(ctx context.Context, environmentID string, req system.PruneAllRequest) (*system.PruneAllResult, bool, error) { slog.InfoContext(ctx, "Starting selective prune operation", "containers", req.Containers, "images", req.Images, @@ -63,11 +72,67 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest "build_cache", req.BuildCache, ) - result := &system.PruneAllResult{Success: true} + activityID, started := s.beginSystemPruneInternal(ctx, environmentID, req) + result := &system.PruneAllResult{Success: true, ActivityID: utils.StringPtrFromTrimmed(activityID)} + if !started { + slog.InfoContext(ctx, "System prune already running", "environmentId", environmentID, "activityId", activityID) + return result, false, nil + } + + defer s.finishSystemPruneInternal(environmentID) + + ctx = s.activityService.Track(ctx, activityID) + s.runSystemPruneInternal(ctx, req, activityID, result) + + return result, true, nil +} + +func (s *SystemService) StartPruneAll(ctx context.Context, environmentID string, req system.PruneAllRequest) *system.PruneAllResult { + activityID, started := s.beginSystemPruneInternal(ctx, environmentID, req) + if !started { + slog.InfoContext(ctx, "System prune already running", "environmentId", environmentID, "activityId", activityID) + return &system.PruneAllResult{Success: true, ActivityID: utils.StringPtrFromTrimmed(activityID)} + } + + backgroundCtx := utils.ActivityRuntimeContext(ctx, nil) + backgroundCtx = s.activityService.Track(backgroundCtx, activityID) + + go func() { + defer s.finishSystemPruneInternal(environmentID) + + result := &system.PruneAllResult{Success: true, ActivityID: utils.StringPtrFromTrimmed(activityID)} + s.runSystemPruneInternal(backgroundCtx, req, activityID, result) + }() + + return &system.PruneAllResult{Success: true, ActivityID: utils.StringPtrFromTrimmed(activityID)} +} + +func (s *SystemService) beginSystemPruneInternal(ctx context.Context, environmentID string, req system.PruneAllRequest) (string, bool) { + s.pruneMu.Lock() + defer s.pruneMu.Unlock() + + if activityID, ok := s.runningPrunes[environmentID]; ok { + return activityID, false + } + + activityID := s.startSystemPruneActivityInternal(ctx, environmentID, req) + s.runningPrunes[environmentID] = activityID + return activityID, true +} + +func (s *SystemService) finishSystemPruneInternal(environmentID string) { + s.pruneMu.Lock() + defer s.pruneMu.Unlock() + + delete(s.runningPrunes, environmentID) +} + +func (s *SystemService) runSystemPruneInternal(ctx context.Context, req system.PruneAllRequest, activityID string, result *system.PruneAllResult) { var mu sync.Mutex // 1. Prune Containers first (sequential) as it may free up other resources if req.Containers != nil && req.Containers.Mode != system.PruneContainerModeNone { + s.appendSystemPruneActivityMessageInternal(ctx, activityID, "Pruning containers", 15) slog.InfoContext(ctx, "Pruning containers...", "mode", req.Containers.Mode, "until", req.Containers.Until) if err := s.pruneContainersInternal(ctx, *req.Containers, result); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Container pruning failed: %v", err)) @@ -80,6 +145,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.Images != nil && req.Images.Mode != system.PruneImageModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning images", 40) slog.InfoContext(groupCtx, "Pruning images...", "mode", req.Images.Mode, "until", req.Images.Until) localResult := &system.PruneAllResult{} if err := s.pruneImagesInternal(groupCtx, *req.Images, localResult); err != nil { @@ -100,6 +166,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.BuildCache != nil && req.BuildCache.Mode != system.PruneBuildCacheModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning build cache", 45) slog.InfoContext(groupCtx, "Pruning build cache...", "mode", req.BuildCache.Mode, "until", req.BuildCache.Until) localResult := &system.PruneAllResult{} if err := s.pruneBuildCacheInternal(groupCtx, *req.BuildCache, localResult); err != nil { @@ -117,6 +184,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.Volumes != nil && req.Volumes.Mode != system.PruneVolumeModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning volumes", 55) slog.InfoContext(groupCtx, "Pruning volumes...", "mode", req.Volumes.Mode) localResult := &system.PruneAllResult{} if err := s.pruneVolumesInternal(groupCtx, *req.Volumes, localResult); err != nil { @@ -137,6 +205,7 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest if req.Networks != nil && req.Networks.Mode != system.PruneNetworkModeNone { g.Go(func() error { + s.appendSystemPruneActivityMessageInternal(groupCtx, activityID, "Pruning networks", 65) slog.InfoContext(groupCtx, "Pruning networks...", "mode", req.Networks.Mode, "until", req.Networks.Until) localResult := &system.PruneAllResult{} if err := s.pruneNetworksInternal(groupCtx, *req.Networks, localResult); err != nil { @@ -158,8 +227,70 @@ func (s *SystemService) PruneAll(ctx context.Context, req system.PruneAllRequest } slog.InfoContext(ctx, "Selective prune operation completed", "success", result.Success, "containers_pruned", len(result.ContainersPruned), "images_deleted", len(result.ImagesDeleted), "volumes_deleted", len(result.VolumesDeleted), "networks_deleted", len(result.NetworksDeleted), "space_reclaimed", result.SpaceReclaimed, "error_count", len(result.Errors)) + s.completeSystemPruneActivityInternal(ctx, activityID, result) +} - return result, nil +func (s *SystemService) startSystemPruneActivityInternal(ctx context.Context, environmentID string, req system.PruneAllRequest) string { + if s.activityService == nil { + return "" + } + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: environmentID, + Type: models.ActivityTypeSystemPrune, + ResourceType: new("system"), + ResourceName: new("Docker resources"), + Step: "Preparing prune", + LatestMessage: "System prune started", + Metadata: models.JSON{ + "containers": req.Containers, + "images": req.Images, + "volumes": req.Volumes, + "networks": req.Networks, + "buildCache": req.BuildCache, + }, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start system prune activity", "error", err) + return "" + } + return activity.ID +} + +func (s *SystemService) appendSystemPruneActivityMessageInternal(ctx context.Context, activityID, message string, progress int) { + if s.activityService == nil || activityID == "" { + return + } + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: message, + Progress: &progress, + Step: message, + }); err != nil { + slog.DebugContext(ctx, "failed to append system prune activity message", "activityId", activityID, "error", err) + } +} + +func (s *SystemService) completeSystemPruneActivityInternal(ctx context.Context, activityID string, result *system.PruneAllResult) { + if s.activityService == nil || activityID == "" || result == nil { + return + } + + status := models.ActivityStatusSuccess + message := "System prune completed" + var errMessage *string + if !result.Success || len(result.Errors) > 0 { + if activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + message = "System prune cancelled" + } else { + status = models.ActivityStatusFailed + message = "System prune completed with errors" + errMessage = new(strings.Join(result.Errors, "; ")) + } + } + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete system prune activity", "activityId", activityID, "error", err) + } } func (s *SystemService) performBatchContainerAction(ctx context.Context, containers []container.Summary, actionName string, shouldProcess func(container.Summary) bool, action func(context.Context, string) error) *containertypes.ActionResult { @@ -201,55 +332,118 @@ func (s *SystemService) performBatchContainerAction(ctx context.Context, contain return result } -func (s *SystemService) StartAllContainers(ctx context.Context) (*containertypes.ActionResult, error) { - containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) - if err != nil { - return &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - }, err - } +func (s *SystemService) StartAllContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { + return s.startMatchingContainersInternal(ctx, environmentID, startMatchingContainersOptionsInternal{ + ResourceName: "All containers", + StartMessage: "Starting all containers", + FailureMessage: "Starting all containers failed", + SuccessMessage: "Started all containers", + ShouldStart: func(c container.Summary) bool { return c.State != "running" }, + }) +} - return s.performBatchContainerAction(ctx, containers, "start", - func(c container.Summary) bool { return c.State != "running" }, - func(ctx context.Context, id string) error { - return s.containerService.StartContainer(ctx, id, systemUser) - }), nil +func (s *SystemService) StartAllStoppedContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { + return s.startMatchingContainersInternal(ctx, environmentID, startMatchingContainersOptionsInternal{ + ResourceName: "Stopped containers", + StartMessage: "Starting stopped containers", + FailureMessage: "Starting stopped containers failed", + SuccessMessage: "Started stopped containers", + ShouldStart: func(c container.Summary) bool { return c.State == "exited" }, + }) +} + +type startMatchingContainersOptionsInternal struct { + ResourceName string + StartMessage string + FailureMessage string + SuccessMessage string + ShouldStart func(container.Summary) bool } -func (s *SystemService) StartAllStoppedContainers(ctx context.Context) (*containertypes.ActionResult, error) { +func (s *SystemService) startMatchingContainersInternal(ctx context.Context, environmentID string, opts startMatchingContainersOptionsInternal) (*containertypes.ActionResult, error) { + activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStart, opts.ResourceName, opts.StartMessage) containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) if err != nil { - return &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - }, err + result := &containertypes.ActionResult{ + Success: false, + Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, + ActivityID: utils.StringPtrFromTrimmed(activityID), + } + s.completeSystemContainerActivityInternal(ctx, activityID, opts.FailureMessage, result) + return result, err } - return s.performBatchContainerAction(ctx, containers, "start", - func(c container.Summary) bool { return c.State == "exited" }, - func(ctx context.Context, id string) error { - return s.containerService.StartContainer(ctx, id, systemUser) - }), nil + result := s.performBatchContainerAction(ctx, containers, "start", opts.ShouldStart, func(ctx context.Context, id string) error { + return s.containerService.StartContainer(ctx, id, systemUser) + }) + result.ActivityID = utils.StringPtrFromTrimmed(activityID) + s.completeSystemContainerActivityInternal(ctx, activityID, opts.SuccessMessage, result) + return result, nil } -func (s *SystemService) StopAllContainers(ctx context.Context) (*containertypes.ActionResult, error) { +func (s *SystemService) StopAllContainers(ctx context.Context, environmentID string) (*containertypes.ActionResult, error) { + activityID := s.startSystemContainerActivityInternal(ctx, environmentID, models.ActivityTypeContainerStop, "All containers", "Stopping all containers") containers, _, _, _, err := s.dockerService.GetAllContainers(ctx) if err != nil { - return &containertypes.ActionResult{ - Success: false, - Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, - }, err + result := &containertypes.ActionResult{ + Success: false, + Errors: []string{fmt.Sprintf("Failed to list containers: %v", err)}, + ActivityID: utils.StringPtrFromTrimmed(activityID), + } + s.completeSystemContainerActivityInternal(ctx, activityID, "Stopping all containers failed", result) + return result, err } - return s.performBatchContainerAction(ctx, containers, "stop", + result := s.performBatchContainerAction(ctx, containers, "stop", func(c container.Summary) bool { // Skip Arcane container return !libupdater.IsArcaneContainer(c.Labels) }, func(ctx context.Context, id string) error { return s.containerService.StopContainer(ctx, id, systemUser) - }), nil + }) + result.ActivityID = utils.StringPtrFromTrimmed(activityID) + s.completeSystemContainerActivityInternal(ctx, activityID, "Stopped all containers", result) + return result, nil +} + +func (s *SystemService) startSystemContainerActivityInternal(ctx context.Context, environmentID string, activityType models.ActivityType, resourceName, message string) string { + if s.activityService == nil { + return "" + } + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: environmentID, + Type: activityType, + ResourceType: new("system"), + ResourceName: &resourceName, + Step: message, + LatestMessage: message, + Metadata: models.JSON{"scope": resourceName}, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start system container activity", "type", activityType, "error", err) + return "" + } + return activity.ID +} + +func (s *SystemService) completeSystemContainerActivityInternal(ctx context.Context, activityID, successMessage string, result *containertypes.ActionResult) { + if s.activityService == nil || activityID == "" || result == nil { + return + } + + status := models.ActivityStatusSuccess + message := successMessage + var errMessage *string + if !result.Success || len(result.Errors) > 0 { + status = models.ActivityStatusFailed + message = strings.Join(result.Errors, "; ") + errMessage = &message + } + + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete system container activity", "activityId", activityID, "error", err) + } } func (s *SystemService) pruneContainersInternal(ctx context.Context, options system.PruneContainersOptions, result *system.PruneAllResult) error { @@ -261,7 +455,7 @@ func (s *SystemService) pruneContainersInternal(ctx context.Context, options sys filterArgs := make(client.Filters) if options.Mode == system.PruneContainerModeOlderThan { if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("container prune mode olderThan requires until") + return errors.New("container prune mode olderThan requires until") } filterArgs = filterArgs.Add("until", options.Until) } @@ -286,14 +480,14 @@ func (s *SystemService) pruneImagesInternal(ctx context.Context, options system. filterArgs := make(client.Filters) switch options.Mode { case system.PruneImageModeNone: - return fmt.Errorf("image prune mode none is not allowed") + return errors.New("image prune mode none is not allowed") case system.PruneImageModeDangling: filterArgs = filterArgs.Add("dangling", "true") case system.PruneImageModeAll: filterArgs = filterArgs.Add("dangling", "false") case system.PruneImageModeOlderThan: if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("image prune mode olderThan requires until") + return errors.New("image prune mode olderThan requires until") } filterArgs = filterArgs.Add("dangling", "false") filterArgs = filterArgs.Add("until", options.Until) @@ -344,7 +538,7 @@ func (s *SystemService) pruneBuildCacheInternal(ctx context.Context, options sys } if options.Mode == system.PruneBuildCacheModeOlderThan { if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("build cache prune mode olderThan requires until") + return errors.New("build cache prune mode olderThan requires until") } pruneOptions.Filters = make(client.Filters) pruneOptions.Filters = pruneOptions.Filters.Add("until", options.Until) @@ -389,7 +583,7 @@ func (s *SystemService) pruneNetworksInternal(ctx context.Context, options syste filterArgs := make(client.Filters) if options.Mode == system.PruneNetworkModeOlderThan { if strings.TrimSpace(options.Until) == "" { - return fmt.Errorf("network prune mode olderThan requires until") + return errors.New("network prune mode olderThan requires until") } filterArgs = filterArgs.Add("until", options.Until) } @@ -407,14 +601,14 @@ func (s *SystemService) pruneNetworksInternal(ctx context.Context, options syste func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRunCommand, error) { if command == "" { - return nil, fmt.Errorf("docker run command must be a non-empty string") + return nil, errors.New("docker run command must be a non-empty string") } cmd := strings.TrimSpace(command) cmd = regexp.MustCompile(`^docker\s+run\s+`).ReplaceAllString(cmd, "") if cmd == "" { - return nil, fmt.Errorf("no arguments found after 'docker run'") + return nil, errors.New("no arguments found after 'docker run'") } result := &system.DockerRunCommand{} @@ -424,7 +618,7 @@ func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRun } if len(tokens) == 0 { - return nil, fmt.Errorf("no valid tokens found in docker run command") + return nil, errors.New("no valid tokens found in docker run command") } if err := dockerrun.ParseTokens(tokens, result); err != nil { @@ -432,7 +626,7 @@ func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRun } if result.Image == "" { - return nil, fmt.Errorf("no Docker image specified in command") + return nil, errors.New("no Docker image specified in command") } return result, nil @@ -440,7 +634,7 @@ func (s *SystemService) ParseDockerRunCommand(command string) (*system.DockerRun func (s *SystemService) ConvertToDockerCompose(parsed *system.DockerRunCommand) (string, string, string, error) { if parsed.Image == "" { - return "", "", "", fmt.Errorf("cannot convert to Docker Compose: no image specified") + return "", "", "", errors.New("cannot convert to Docker Compose: no image specified") } serviceName := parsed.Name diff --git a/backend/internal/services/system_upgrade_service.go b/backend/internal/services/system_upgrade_service.go index 5ef6490010..cf0a59f69b 100644 --- a/backend/internal/services/system_upgrade_service.go +++ b/backend/internal/services/system_upgrade_service.go @@ -21,7 +21,7 @@ import ( "github.com/moby/moby/client" ) -var ArcaneUpgraderImage = "ghcr.io/getarcaneapp/arcane:latest" +const defaultArcaneUpgraderImageInternal = "ghcr.io/getarcaneapp/arcane:latest" type SystemUpgradeService struct { upgrading atomic.Bool @@ -114,14 +114,15 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo // Use the same image reference as the currently running Arcane container for the upgrader. // This avoids mismatches where a newer/older upgrader CLI expects different behavior. + upgraderImage := defaultArcaneUpgraderImageInternal if currentContainer.Config != nil { if img := strings.TrimSpace(currentContainer.Config.Image); img != "" { - ArcaneUpgraderImage = img + upgraderImage = img } } - slog.Debug("Using upgrader image", "image", ArcaneUpgraderImage) + slog.Debug("Using upgrader image", "image", upgraderImage) - slog.Info("Spawning upgrade CLI command", "containerName", containerName, "upgraderImage", ArcaneUpgraderImage) + slog.Info("Spawning upgrade CLI command", "containerName", containerName, "upgraderImage", upgraderImage) // Spawn the upgrade command in a detached container // This will run independently of the current container @@ -131,16 +132,16 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo } // Pull the upgrader image first to ensure it exists - slog.Info("Pulling upgrader image", "image", ArcaneUpgraderImage) + slog.Info("Pulling upgrader image", "image", upgraderImage) settings := s.settingsService.GetSettingsConfig() pullCtx, pullCancel := timeouts.WithTimeout(ctx, settings.DockerImagePullTimeout.AsInt(), timeouts.DefaultDockerImagePull) defer pullCancel() - pullReader, err := dockerClient.ImagePull(pullCtx, ArcaneUpgraderImage, client.ImagePullOptions{}) + pullReader, err := dockerClient.ImagePull(pullCtx, upgraderImage, client.ImagePullOptions{}) if err != nil { if errors.Is(pullCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("upgrader image pull timed out for %s (increase DOCKER_IMAGE_PULL_TIMEOUT or setting)", ArcaneUpgraderImage) + return fmt.Errorf("upgrader image pull timed out for %s (increase DOCKER_IMAGE_PULL_TIMEOUT or setting)", upgraderImage) } return fmt.Errorf("pull upgrader image: %w", err) } @@ -152,7 +153,7 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo if closeErr := pullReader.Close(); closeErr != nil { slog.Warn("Failed to close upgrader image pull reader", "error", closeErr) } - slog.Info("Upgrader image pulled successfully", "image", ArcaneUpgraderImage) + slog.Info("Upgrader image pulled successfully", "image", upgraderImage) // Try to get the /app/data mount from current container so upgrade logs persist. appDataMount := dockerutils.MountForDestination(currentContainer.Mounts, "/app/data", "/app/data") @@ -180,7 +181,7 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo } config := &containertypes.Config{ - Image: ArcaneUpgraderImage, + Image: upgraderImage, Cmd: []string{binaryPath, "upgrade", "--container", containerName}, Env: runtimeOptions.ContainerEnv, Labels: map[string]string{ diff --git a/backend/internal/services/template_service.go b/backend/internal/services/template_service.go index 39b7f543ca..a60a7b231c 100644 --- a/backend/internal/services/template_service.go +++ b/backend/internal/services/template_service.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "os" "path/filepath" @@ -147,7 +148,7 @@ func (s *TemplateService) ensureRemoteTemplatesLoaded(ctx context.Context) error s.remoteMu.Unlock() }() - if _, err := s.refreshRemoteTemplates(bgCtx); err != nil { + if err := s.refreshRemoteTemplates(bgCtx); err != nil { slog.WarnContext(bgCtx, "background remote template refresh failed", "error", err) } }(ctx) @@ -160,21 +161,20 @@ func (s *TemplateService) ensureRemoteTemplatesLoaded(ctx context.Context) error s.remoteMu.Unlock() // No cache at all, must block - _, err := s.refreshRemoteTemplates(ctx) - return err + return s.refreshRemoteTemplates(ctx) } -func (s *TemplateService) refreshRemoteTemplates(ctx context.Context) ([]models.ComposeTemplate, error) { +func (s *TemplateService) refreshRemoteTemplates(ctx context.Context) error { templates, err := s.loadRemoteTemplates(ctx) if err != nil { - return nil, fmt.Errorf("failed to load remote templates: %w", err) + return fmt.Errorf("failed to load remote templates: %w", err) } s.remoteMu.Lock() defer s.remoteMu.Unlock() s.remoteCache.templates = templates s.remoteCache.lastFetch = time.Now() - return templates, nil + return nil } func (s *TemplateService) GetAllTemplates(ctx context.Context) ([]models.ComposeTemplate, error) { @@ -286,7 +286,7 @@ func (s *TemplateService) GetTemplate(ctx context.Context, id string) (*models.C // silently returned empty. if strings.HasPrefix(id, remoteIDPrefix+":") { slog.InfoContext(ctx, "remote template not in cache, forcing registry refresh", "templateID", id, "cacheSize", s.remoteCacheSizeInternal()) - if _, refreshErr := s.refreshRemoteTemplates(ctx); refreshErr != nil { + if refreshErr := s.refreshRemoteTemplates(ctx); refreshErr != nil { return nil, fmt.Errorf("template %q not found and registry refresh failed: %w", id, refreshErr) } if found := s.lookupRemoteFromCacheInternal(id); found != nil { @@ -344,7 +344,7 @@ func (s *TemplateService) UpdateTemplate(ctx context.Context, id string, updates } if existing.IsRemote { - return fmt.Errorf("cannot update remote template") + return errors.New("cannot update remote template") } existing.Name = updates.Name @@ -372,20 +372,19 @@ func (s *TemplateService) DeleteTemplate(ctx context.Context, id string) error { } if existing.IsRemote { - return fmt.Errorf("cannot delete remote template directly") + return errors.New("cannot delete remote template directly") } baseDir, err := s.getTemplatesDirectoryInternal(ctx) if err != nil { return fmt.Errorf("failed to get templates directory: %w", err) - } else { - templatePath := filepath.Join(baseDir, existing.Name) + } - if stat, err := os.Stat(templatePath); err == nil && stat.IsDir() { - if _, err := projects.DetectComposeFile(templatePath); err == nil { - if err := os.RemoveAll(templatePath); err != nil { - return fmt.Errorf("failed to delete template directory: %w", err) - } + templatePath := filepath.Join(baseDir, existing.Name) + if stat, err := os.Stat(templatePath); err == nil && stat.IsDir() { + if _, err := projects.DetectComposeFile(templatePath); err == nil { + if err := os.RemoveAll(templatePath); err != nil { + return fmt.Errorf("failed to delete template directory: %w", err) } } } @@ -464,9 +463,7 @@ func (s *TemplateService) GetRegistryFetchErrors() map[string]string { s.registryMu.RLock() defer s.registryMu.RUnlock() out := make(map[string]string, len(s.registryErrors)) - for k, v := range s.registryErrors { - out[k] = v - } + maps.Copy(out, s.registryErrors) return out } @@ -474,7 +471,7 @@ func (s *TemplateService) CreateRegistry(ctx context.Context, registry *models.T // Hydrate metadata if needed if registry.Name == "" || registry.Description == "" { if registry.URL == "" { - return fmt.Errorf("registry URL is required") + return errors.New("registry URL is required") } if manifest, err := s.fetchRegistryManifest(ctx, registry.URL); err == nil { if registry.Name == "" { @@ -637,7 +634,7 @@ func (s *TemplateService) doGET(ctx context.Context, url string) ([]byte, error) if err != nil { return nil, err } - resp, err := client.Do(req) //nolint:gosec // intentional request to configured template registry URL + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch %s: %w", url, err) } @@ -669,7 +666,7 @@ func (s *TemplateService) fetchRegistryTemplates(ctx context.Context, reg *model req.Header.Set("If-Modified-Since", fetchMeta.LastModified) } - resp, err := client.Do(req) //nolint:gosec // intentional request to configured template registry URL + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -679,7 +676,7 @@ func (s *TemplateService) fetchRegistryTemplates(ctx context.Context, reg *model if fetchMeta != nil { return cloneRemoteTemplates(fetchMeta.Templates), nil } - return nil, fmt.Errorf("received 304 without cached data") + return nil, errors.New("received 304 without cached data") } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) @@ -723,7 +720,7 @@ func (s *TemplateService) fetchRegistryManifest(ctx context.Context, url string) return nil, fmt.Errorf("failed to parse registry JSON: %w", err) } if reg.Name == "" || len(reg.Templates) == 0 { - return nil, fmt.Errorf("invalid registry manifest: missing required fields (name, templates)") + return nil, errors.New("invalid registry manifest: missing required fields (name, templates)") } return ®, nil } @@ -769,7 +766,7 @@ func (s *TemplateService) FetchTemplateContent(ctx context.Context, template *mo func (s *TemplateService) fetchRemoteTemplateFiles(ctx context.Context, template *models.ComposeTemplate) (string, string, error) { if template == nil || template.Metadata == nil || template.Metadata.RemoteURL == nil { - return "", "", fmt.Errorf("not a remote template or missing remote URL") + return "", "", errors.New("not a remote template or missing remote URL") } composeContent, err := s.fetchURL(ctx, *template.Metadata.RemoteURL) @@ -842,7 +839,7 @@ func (s *TemplateService) newSafeRequestInternal(ctx context.Context, method, ra if client == nil { client = s.newSafeHTTPClientInternal() if client == nil { - return nil, nil, fmt.Errorf("failed to configure safe HTTP client") + return nil, nil, errors.New("failed to configure safe HTTP client") } s.safeHTTPClient = client } @@ -857,7 +854,7 @@ func (s *TemplateService) newSafeRequestInternal(ctx context.Context, method, ra func (s *TemplateService) DownloadTemplate(ctx context.Context, remoteTemplate *models.ComposeTemplate) (*models.ComposeTemplate, error) { if !remoteTemplate.IsRemote { - return nil, fmt.Errorf("template is not remote") + return nil, errors.New("template is not remote") } base := s.templateBaseFromRemote(remoteTemplate) diff --git a/backend/internal/services/template_service_test.go b/backend/internal/services/template_service_test.go index dff9e55898..30d8835e9c 100644 --- a/backend/internal/services/template_service_test.go +++ b/backend/internal/services/template_service_test.go @@ -355,8 +355,8 @@ func TestGetAllTemplatesPaginated_FiltersByType(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { templates, _, err := service.GetAllTemplatesPaginated(context.Background(), pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - Filters: map[string]string{"type": tt.typeFilter}, + Params: pagination.Params{Start: 0, Limit: 20}, + Filters: map[string]string{"type": tt.typeFilter}, }) require.NoError(t, err) require.ElementsMatch(t, tt.wantIDs, templateIDsInternal(templates)) diff --git a/backend/internal/services/updater_service.go b/backend/internal/services/updater_service.go index 45e210c94a..b868b63102 100644 --- a/backend/internal/services/updater_service.go +++ b/backend/internal/services/updater_service.go @@ -21,9 +21,12 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" projectspkg "github.com/getarcaneapp/arcane/backend/pkg/projects" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/updater" ) @@ -40,6 +43,7 @@ type UpdaterService struct { imageService *ImageService notificationService *NotificationService upgradeService selfUpgradeService + activityService *ActivityService statusMu sync.RWMutex updatingContainers map[string]bool @@ -63,6 +67,7 @@ func NewUpdaterService( imageSvc *ImageService, notifications *NotificationService, upgrade selfUpgradeService, + activityService *ActivityService, ) *UpdaterService { return &UpdaterService{ db: db, @@ -75,6 +80,7 @@ func NewUpdaterService( imageService: imageSvc, notificationService: notifications, upgradeService: upgrade, + activityService: activityService, updatingContainers: map[string]bool{}, updatingProjects: map[string]bool{}, } @@ -84,10 +90,19 @@ func NewUpdaterService( // per-resource result summary. When dryRun is true, it reports the planned // actions without mutating containers or projects. // -//nolint:gocognit -func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*updater.Result, error) { +//nolint:gocognit,gocyclo,maintidx // large but sequential update-orchestration flow; refactor tracked separately +func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (out *updater.Result, err error) { start := time.Now() - out := &updater.Result{Items: []updater.ResourceResult{}} + out = &updater.Result{Items: []updater.ResourceResult{}} + activityID := s.startAutoUpdateActivityInternal(ctx, dryRun) + out.ActivityID = utils.StringPtrFromTrimmed(activityID) + ctx = s.activityService.Track(ctx, activityID) + defer func() { + if out != nil { + out.ActivityID = utils.StringPtrFromTrimmed(activityID) + } + s.completeAutoUpdateActivityInternal(ctx, activityID, out, err) + }() var records []models.ImageUpdateRecord if err := s.db.WithContext(ctx).Where("has_update = ?", true).Find(&records).Error; err != nil { @@ -95,6 +110,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } // debug: how many pending records and dryRun flag slog.DebugContext(ctx, "ApplyPending: found pending image update records", "records", len(records), "dryRun", dryRun) + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, fmt.Sprintf("Found %d pending image update records", len(records)), "Planning updates", 10) if len(records) == 0 { out.Duration = time.Since(start).String() @@ -148,6 +164,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if len(plans) == 0 { + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "No active resources need updates", "Planning updates", 100) out.Duration = time.Since(start).String() return out, nil } @@ -174,6 +191,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update for i := range plans { p := plans[i] + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Checking update for "+p.oldRef, "Checking image", 20) item := updater.ResourceResult{ ResourceID: p.oldRef, ResourceType: "image", @@ -227,7 +245,10 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if !skipPull { - if err := s.imageService.PullImage(ctx, p.newRef, io.Discard, systemUser, nil); err != nil { + progressWriter := activitylib.NewWriter(ctx, s.activityService, activityID, io.Discard, "Pulling updated images") + err := s.imageService.PullImage(ctx, p.newRef, progressWriter, systemUser, nil) + activitylib.FlushWriter(progressWriter) + if err != nil { item.Status = "failed" item.Error = err.Error() out.Failed++ @@ -271,6 +292,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if !dryRun && (len(oldIDToNewRef) > 0 || len(oldRefToNewRef) > 0) { + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Restarting containers that use updated images", "Restarting containers", 75) results, err := s.restartContainersUsingOldIDs(ctx, oldIDToNewRef, oldRefToNewRef) if err != nil { slog.Warn("container restarts had errors", "err", err) @@ -321,6 +343,7 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update } if !dryRun && len(oldIDSet) > 0 { + s.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Pruning old image IDs", "Pruning images", 90) ids := make([]string, 0, len(oldIDSet)) for id := range oldIDSet { ids = append(ids, id) @@ -387,13 +410,110 @@ func (s *UpdaterService) ApplyPending(ctx context.Context, dryRun bool) (*update return out, nil } +func (s *UpdaterService) startAutoUpdateActivityInternal(ctx context.Context, dryRun bool) string { + if s.activityService == nil { + return "" + } + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeAutoUpdate, + ResourceType: new("system"), + ResourceName: new("Auto update"), + Step: "Planning updates", + LatestMessage: "Auto-update run started", + Metadata: models.JSON{"dryRun": dryRun}, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start auto-update activity", "error", err) + return "" + } + return activity.ID +} + +func (s *UpdaterService) startSingleContainerUpdateActivityInternal(ctx context.Context, containerID string) string { + if s.activityService == nil { + return "" + } + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: "0", + Type: models.ActivityTypeAutoUpdate, + ResourceType: new("container"), + ResourceID: &containerID, + ResourceName: new(containerID), + Step: "Updating container", + LatestMessage: "Container update started", + Metadata: models.JSON{"containerID": containerID}, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start container update activity", "containerID", containerID, "error", err) + return "" + } + return activity.ID +} + +func (s *UpdaterService) appendAutoUpdateActivityMessageInternal(ctx context.Context, activityID, message, step string, progress int) { + if s.activityService == nil || activityID == "" { + return + } + if step == "" { + step = message + } + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: message, + Progress: &progress, + Step: step, + }); err != nil { + slog.DebugContext(ctx, "failed to append auto-update activity message", "activityId", activityID, "error", err) + } +} + +func (s *UpdaterService) completeAutoUpdateActivityInternal(ctx context.Context, activityID string, result *updater.Result, applyErr error) { + if s.activityService == nil || activityID == "" { + return + } + + status := models.ActivityStatusSuccess + message := "Auto-update run completed" + var errMessage *string + if applyErr != nil { + status = models.ActivityStatusFailed + errText := applyErr.Error() + errMessage = &errText + message = errText + } else if result != nil && result.Failed > 0 { + status = models.ActivityStatusFailed + errText := fmt.Sprintf("%d update action(s) failed", result.Failed) + errMessage = &errText + message = errText + } + if status == models.ActivityStatusFailed && activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + message = "Auto-update cancelled" + errMessage = nil + } + + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errMessage); err != nil { + slog.DebugContext(ctx, "failed to complete auto-update activity", "activityId", activityID, "error", err) + } +} + // UpdateSingleContainer updates a single container by ID to the latest available image. // It pulls the new image, stops the container, removes it, and recreates it with the new image. // -//nolint:gocognit // single-container update flow is intentionally linear with explicit early exits for failure reporting -func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID string) (*updater.Result, error) { +//nolint:gocognit,gocyclo,maintidx // single-container update flow is intentionally linear with explicit early exits for failure reporting +func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID string) (out *updater.Result, err error) { start := time.Now() - out := &updater.Result{Items: []updater.ResourceResult{}} + out = &updater.Result{Items: []updater.ResourceResult{}} + activityID := s.startSingleContainerUpdateActivityInternal(ctx, containerID) + out.ActivityID = utils.StringPtrFromTrimmed(activityID) + ctx = s.activityService.Track(ctx, activityID) + defer func() { + if out != nil { + out.ActivityID = utils.StringPtrFromTrimmed(activityID) + } + s.completeAutoUpdateActivityInternal(ctx, activityID, out, err) + }() slog.InfoContext(ctx, "UpdateSingleContainer: starting", "containerID", containerID) dcli, err := s.dockerService.GetClient(ctx) if err != nil { @@ -418,7 +538,7 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID return nil, fmt.Errorf("container not found: %s", containerID) } - containerName := s.getContainerName(*targetContainer) + containerName := dockerutil.ContainerSummaryName(*targetContainer) slog.InfoContext(ctx, "UpdateSingleContainer: found container", "containerID", containerID, "name", containerName, "image", targetContainer.Image) // Inspect container to get full config (needed for label-based controls) @@ -469,6 +589,21 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID return out, nil } + if libupdater.IsSwarmTask(labels) && !isArcaneContainer { + slog.InfoContext(ctx, "UpdateSingleContainer: skipping swarm task container", "containerID", containerID) + out.Items = append(out.Items, updater.ResourceResult{ + ResourceID: targetContainer.ID, + ResourceType: "container", + ResourceName: containerName, + Status: "skipped", + Error: "swarm service; update at the service level", + }) + out.Skipped++ + out.Checked = 1 + out.Duration = time.Since(start).String() + return out, nil + } + // Resolve the best pullable image reference for this container. configImageRef := "" if inspectBefore.Config != nil { @@ -593,8 +728,8 @@ func (s *UpdaterService) UpdateSingleContainer(ctx context.Context, containerID } // Update the container - projectName := inspect.Config.Labels["com.docker.compose.project"] - serviceName := inspect.Config.Labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(inspect.Config.Labels) + serviceName := dockerutil.ComposeServiceLabel(inspect.Config.Labels) if projectName != "" && serviceName != "" { slog.InfoContext(ctx, "UpdateSingleContainer: detected compose container, using project-based update", "containerID", containerID, "project", projectName, "service", serviceName) @@ -756,14 +891,13 @@ func (s *UpdaterService) GetHistory(ctx context.Context, limit int) ([]models.Au // --- internals --- -//nolint:gocognit func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summary, inspect container.InspectResponse, newRef string) error { dcli, err := s.dockerService.GetClient(ctx) if err != nil { return fmt.Errorf("docker connect: %w", err) } - name := s.getContainerName(cnt) + name := dockerutil.ContainerSummaryName(cnt) labels := inspect.Config.Labels isArcane := libupdater.IsArcaneContainer(labels) @@ -771,7 +905,7 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ // This method should not be called for Arcane containers if isArcane { slog.ErrorContext(ctx, "updateContainer: called for Arcane container - should use CLI upgrade instead", "containerId", cnt.ID, "containerName", name) - return fmt.Errorf("arcane containers must use CLI upgrade method (TriggerUpgradeViaCLI), not inline update") + return errors.New("arcane containers must use CLI upgrade method (TriggerUpgradeViaCLI), not inline update") } slog.DebugContext(ctx, "updateContainer: starting update", "containerId", cnt.ID, "containerName", name, "newRef", newRef, "isArcane", isArcane) @@ -934,11 +1068,11 @@ func normalizeImageUpdateRefInternal(imageRef string) string { return parts.NormalizedRef } -func addNormalizedImageUpdateRefInternal(ctx context.Context, out map[string]struct{}, imageRef, logMessage string, attrs ...any) bool { +func addNormalizedImageUpdateRefInternal(ctx context.Context, out map[string]struct{}, imageRef, logMessage string, attrs ...any) { normalizedRef := normalizeImageUpdateRefInternal(imageRef) if normalizedRef != "" { out[normalizedRef] = struct{}{} - return true + return } args := slices.Clone(attrs) @@ -948,7 +1082,6 @@ func addNormalizedImageUpdateRefInternal(ctx context.Context, out map[string]str } else { slog.Debug(logMessage, args...) } - return false } func (s *UpdaterService) stripDigest(ref string) string { @@ -1128,7 +1261,7 @@ func activeComposeProjectNameSetInternal(projects []models.Project) map[string]s func (s *UpdaterService) collectUsedImagesFromComposeContainersInternal(ctx context.Context, composeContainers []container.Summary, activeProjectNames map[string]struct{}, out map[string]struct{}) { for _, c := range composeContainers { - projectName := strings.TrimSpace(c.Labels["com.docker.compose.project"]) + projectName := dockerutil.ComposeProjectLabel(c.Labels) if projectName == "" { continue } @@ -1177,17 +1310,6 @@ func (s *UpdaterService) getNormalizedTagsForContainer(ctx context.Context, dcli return out } -func (s *UpdaterService) getContainerName(cnt container.Summary) string { - if len(cnt.Names) > 0 { - n := cnt.Names[0] - if strings.HasPrefix(n, "/") { - return n[1:] - } - return n - } - return cnt.ID[:12] -} - func (s *UpdaterService) recordRun(ctx context.Context, item updater.ResourceResult) error { rec := &models.AutoUpdateRecord{ ResourceID: item.ResourceID, @@ -1252,7 +1374,7 @@ type restartPlan struct { implicit bool } -//nolint:gocognit +//nolint:gocognit,gocyclo,maintidx // restart-orchestration flow remaps old/new container IDs in one pass; refactor tracked separately func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldIDToNewRef map[string]string, oldRefToNewRef map[string]string) ([]updater.ResourceResult, error) { dcli, err := s.dockerService.GetClient(ctx) if err != nil { @@ -1312,6 +1434,11 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID continue } + if libupdater.IsSwarmTask(c.Labels) && !libupdater.IsArcaneContainer(c.Labels) { + slog.DebugContext(ctx, "restartContainersUsingOldIDs: skipping swarm task container", "containerId", c.ID, "names", c.Names) + continue + } + // Skip the Docker socket proxy container to preserve Arcane's Docker connectivity (#1881) if dockerProxyName != "" { isProxy := false @@ -1333,7 +1460,7 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID c.Labels = map[string]string{} } - name := s.getContainerName(c) + name := dockerutil.ContainerSummaryName(c) containersWithDeps = append(containersWithDeps, libupdater.ContainerWithDeps{ Container: c, Name: name, @@ -1432,8 +1559,8 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID if p == nil || p.newRef == "" || p.inspect == nil || p.inspect.Config == nil { continue } - projectName := p.inspect.Config.Labels["com.docker.compose.project"] - serviceName := p.inspect.Config.Labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(p.inspect.Config.Labels) + serviceName := dockerutil.ComposeServiceLabel(p.inspect.Config.Labels) if projectName != "" && serviceName != "" { projectID, ok := lookupComposeProjectIDInternal(projectName, projectNameToID) if !ok { @@ -1517,8 +1644,8 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID endProjectStatus := s.beginProjectUpdateInternal(composeProjectNameFromLabelsInternal(labels)) defer endProjectStatus() - projectName := labels["com.docker.compose.project"] - serviceName := labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(labels) + serviceName := dockerutil.ComposeServiceLabel(labels) var proj *models.Project if projectName != "" { if projectID, ok := lookupComposeProjectIDInternal(projectName, projectNameToID); ok { @@ -1678,8 +1805,8 @@ func (s *UpdaterService) lazyRegisterComposeProjectInternal( if p.newRef == "" || p.inspect == nil || p.inspect.Config == nil { return } - projectName := p.inspect.Config.Labels["com.docker.compose.project"] - serviceName := p.inspect.Config.Labels["com.docker.compose.service"] + projectName := dockerutil.ComposeProjectLabel(p.inspect.Config.Labels) + serviceName := dockerutil.ComposeServiceLabel(p.inspect.Config.Labels) if projectName == "" || serviceName == "" { return } @@ -1742,7 +1869,7 @@ func composeProjectNameFromLabelsInternal(labels map[string]string) string { if len(labels) == 0 { return "" } - return strings.TrimSpace(labels["com.docker.compose.project"]) + return dockerutil.ComposeProjectLabel(labels) } func lookupComposeProjectIDInternal(projectName string, projectNameToID map[string]string) (string, bool) { @@ -1833,14 +1960,14 @@ func (s *UpdaterService) logAutoUpdate(ctx context.Context, sev models.EventSeve img = fmt.Sprint(metadata["imageOld"]) } if img != "" { - title = fmt.Sprintf("Auto-update: image pull %s", img) + title = "Auto-update: image pull " + img } else { title = "Auto-update: image pull" } case "image_prune": imageID := fmt.Sprint(metadata["imageId"]) if imageID != "" { - title = fmt.Sprintf("Auto-update: image prune %s", imageID) + title = "Auto-update: image prune " + imageID } else { title = "Auto-update: image prune" } @@ -1850,7 +1977,7 @@ func (s *UpdaterService) logAutoUpdate(ctx context.Context, sev models.EventSeve name = fmt.Sprint(metadata["containerId"]) } if name != "" { - title = fmt.Sprintf("Auto-update: container %s", name) + title = "Auto-update: container " + name } else { title = "Auto-update: container" } @@ -1860,7 +1987,7 @@ func (s *UpdaterService) logAutoUpdate(ctx context.Context, sev models.EventSeve name = fmt.Sprint(metadata["projectId"]) } if name != "" { - title = fmt.Sprintf("Auto-update: project %s", name) + title = "Auto-update: project " + name } else { title = "Auto-update: project" } diff --git a/backend/internal/services/updater_service_test.go b/backend/internal/services/updater_service_test.go index a2669a5c96..f6918e3b2e 100644 --- a/backend/internal/services/updater_service_test.go +++ b/backend/internal/services/updater_service_test.go @@ -270,6 +270,124 @@ func TestUpdaterService_CLICalledWithSystemUser(t *testing.T) { assert.Equal(t, systemUser.Username, mockUpgrade.capturedUser.Username) } +func TestUpdaterService_UpdateSingleContainerSkipsSwarmTaskInternal(t *testing.T) { + ctx := context.Background() + const containerID = "swarm-task-1" + labels := map[string]string{ + libupdater.LabelSwarmServiceID: "service-1", + libupdater.LabelSwarmServiceName: "web", + } + pullCalled := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/containers/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode([]container.Summary{ + { + ID: containerID, + Names: []string{"/web.1.task"}, + Image: "nginx:latest", + ImageID: "sha256:old-image", + Labels: labels, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(container.InspectResponse{ + ID: containerID, + Image: "sha256:old-image", + Config: &container.Config{ + Image: "nginx:latest", + Labels: labels, + }, + })) + case strings.HasSuffix(r.URL.Path, "/images/create"): + pullCalled = true + http.Error(w, "unexpected pull", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dcli, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost("tcp://"+srv.Listener.Addr().String()), + dockerclient.WithVersion("1.46"), + ) + require.NoError(t, err) + + db := setupActivityServiceTestDBInternal(t) + svc := NewUpdaterService(nil, nil, &DockerClientService{client: dcli, config: &config.Config{}}, nil, nil, nil, nil, nil, nil, nil, NewActivityService(db)) + + out, err := svc.UpdateSingleContainer(ctx, containerID) + require.NoError(t, err) + require.NotNil(t, out) + require.Len(t, out.Items, 1) + assert.Equal(t, "skipped", out.Items[0].Status) + assert.Contains(t, out.Items[0].Error, "swarm service") + assert.False(t, pullCalled, "swarm task update must not pull or recreate the task container") +} + +func TestUpdaterService_RestartContainersUsingOldIDsSkipsSwarmTaskInternal(t *testing.T) { + ctx := context.Background() + const containerID = "swarm-task-1" + labels := map[string]string{ + libupdater.LabelSwarmServiceID: "service-1", + libupdater.LabelSwarmServiceName: "web", + } + stopCalled := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/containers/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode([]container.Summary{ + { + ID: containerID, + Names: []string{"/web.1.task"}, + Image: "nginx:latest", + ImageID: "sha256:old-image", + Labels: labels, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(container.InspectResponse{ + ID: containerID, + Name: "/web.1.task", + Image: "sha256:old-image", + Config: &container.Config{ + Image: "nginx:latest", + Labels: labels, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/stop"): + stopCalled = true + http.Error(w, "unexpected stop", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dcli, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost("tcp://"+srv.Listener.Addr().String()), + dockerclient.WithVersion("1.46"), + ) + require.NoError(t, err) + + settingsDB := setupUpdaterServiceSettingsDB(t) + settings, err := NewSettingsService(ctx, settingsDB) + require.NoError(t, err) + svc := NewUpdaterService(nil, settings, &DockerClientService{client: dcli, config: &config.Config{}}, nil, nil, nil, nil, nil, nil, nil, nil) + + results, err := svc.restartContainersUsingOldIDs(ctx, map[string]string{"sha256:old-image": "nginx:latest"}, nil) + require.NoError(t, err) + assert.Empty(t, results) + assert.False(t, stopCalled, "swarm task update must not stop the orchestrator-owned task container") +} + // TestUpdaterService_UpgradeServiceNotNilCheck verifies the nil check logic func TestUpdaterService_UpgradeServiceNotNilCheck(t *testing.T) { ctx := context.Background() @@ -327,7 +445,7 @@ func TestUpdaterService_LazyRegisterComposeProjectInternal_AddsServicesForRegist } require.NoError(t, db.Create(project).Error) - projectService := NewProjectService(db, nil, nil, nil, nil, nil, config.Load()) + projectService := NewProjectService(db, nil, nil, nil, nil, nil, nil, config.Load()) svc := &UpdaterService{projectService: projectService} projectNameToID := map[string]string{} @@ -579,6 +697,24 @@ func setupUpdaterServiceTestDB(t *testing.T) *database.DB { return &database.DB{DB: db} } +func TestUpdaterService_AppendAutoUpdateActivityMessageUsesExplicitStepInternal(t *testing.T) { + ctx := context.Background() + db := setupActivityServiceTestDBInternal(t) + activityService := NewActivityService(db) + svc := &UpdaterService{activityService: activityService} + activityID := svc.startAutoUpdateActivityInternal(ctx, false) + require.NotEmpty(t, activityID) + + svc.appendAutoUpdateActivityMessageInternal(ctx, activityID, "Found 5 pending image update records", "Planning updates", 10) + + var activity models.Activity + require.NoError(t, db.WithContext(ctx).First(&activity, "id = ?", activityID).Error) + require.NotNil(t, activity.Progress) + assert.Equal(t, 10, *activity.Progress) + assert.Equal(t, "Found 5 pending image update records", activity.LatestMessage) + assert.Equal(t, "Planning updates", activity.Step) +} + func TestUpdaterService_ClearImageUpdateRecordByID_AvoidsRepoCanonicalMismatch(t *testing.T) { ctx := context.Background() db := setupUpdaterServiceTestDB(t) diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go index 7a34d7389b..8bdb1dd33c 100644 --- a/backend/internal/services/user_service.go +++ b/backend/internal/services/user_service.go @@ -17,6 +17,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/database" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/getarcaneapp/arcane/backend/pkg/utils/dbutil" "github.com/getarcaneapp/arcane/types/user" @@ -42,6 +43,7 @@ func DefaultArgon2Params() *Argon2Params { type UserService struct { db *database.DB + roleService *RoleService argon2Params *Argon2Params } @@ -54,6 +56,14 @@ func NewUserService(db *database.DB) *UserService { } } +// WithRoleService wires the RoleService dependency. Separated from the +// constructor so the bootstrap can construct UserService first (RoleService +// itself has no UserService dependency). +func (s *UserService) WithRoleService(roleService *RoleService) *UserService { + s.roleService = roleService + return s +} + func (s *UserService) hashPassword(password string) (string, error) { salt := make([]byte, s.argon2Params.saltLength) _, err := rand.Read(salt) @@ -88,7 +98,7 @@ func (s *UserService) validateBcryptPassword(hash, password string) error { func (s *UserService) validateArgon2Password(encodedHash, password string) error { parts := strings.Split(encodedHash, "$") if len(parts) != 6 { - return fmt.Errorf("invalid hash format") + return errors.New("invalid hash format") } var version int @@ -97,7 +107,7 @@ func (s *UserService) validateArgon2Password(encodedHash, password string) error return err } if version != argon2.Version { - return fmt.Errorf("incompatible version of argon2") + return errors.New("incompatible version of argon2") } var memory, iterations uint32 @@ -119,14 +129,14 @@ func (s *UserService) validateArgon2Password(encodedHash, password string) error hashLen := len(decodedHash) if hashLen < 0 || hashLen > 0x7fffffff { - return fmt.Errorf("invalid hash length") + return errors.New("invalid hash length") } comparisonHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(hashLen)) // constant-time compare if subtle.ConstantTimeCompare(comparisonHash, decodedHash) != 1 { - return fmt.Errorf("invalid password") + return errors.New("invalid password") } return nil @@ -163,27 +173,15 @@ func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models func (s *UserService) UpdateUser(ctx context.Context, user *models.User) (*models.User, error) { err := dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { - var existing models.User if err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", user.ID). - First(&existing).Error; err != nil { + First(&models.User{}).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrUserNotFound } return fmt.Errorf("failed to load user: %w", err) } - - if userHasRoleInternal(existing.Roles, "admin") && !userHasRoleInternal(user.Roles, "admin") { - remainingAdmins, err := s.remainingAdminCountExcludingUserInternal(tx, user.ID) - if err != nil { - return err - } - if remainingAdmins == 0 { - return ErrCannotRemoveLastAdmin - } - } - if err := tx.Save(user).Error; err != nil { return fmt.Errorf("failed to update user: %w", err) } @@ -218,7 +216,7 @@ func (s *UserService) AttachOidcSubjectTransactional(ctx context.Context, userID // If already linked to a different subject, abort if u.OidcSubjectId != nil && *u.OidcSubjectId != "" && *u.OidcSubjectId != subject { - return fmt.Errorf("user already linked to another OIDC subject") + return errors.New("user already linked to another OIDC subject") } // Link subject @@ -251,64 +249,127 @@ func (s *UserService) CreateDefaultAdmin(ctx context.Context) error { return fmt.Errorf("failed to hash default admin password: %w", err) } - return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { + // Step 1: ensure the default admin user row exists. If the users table is + // empty we create it; otherwise we find the existing `arcane` user (if any). + // Either way the role assignment is reconciled below — idempotently — so + // upgrades from older builds that didn't grant the role get patched up. + var adminUserID string + err = dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { var count int64 if err := tx.Model(&models.User{}).Count(&count).Error; err != nil { return fmt.Errorf("failed to count users: %w", err) } - if count > 0 { - slog.WarnContext(ctx, "Users already exist, skipping default admin creation") + if count == 0 { + email := "admin@localhost" + displayName := "Arcane Admin" + userModel := &models.User{ + Username: "arcane", + Email: new(email), + DisplayName: new(displayName), + PasswordHash: hashedPassword, + RequiresPasswordChange: true, + } + if err := tx.Create(userModel).Error; err != nil { + return fmt.Errorf("failed to create default admin user: %w", err) + } + adminUserID = userModel.ID + slog.InfoContext(ctx, "👑 Default admin user created!") + slog.InfoContext(ctx, "🔑 Username: arcane") + slog.InfoContext(ctx, "🔑 Password: arcane-admin") + slog.InfoContext(ctx, "⚠️ User will be prompted to change password on first login") return nil } - email := "admin@localhost" - displayName := "Arcane Admin" - userModel := &models.User{ - Username: "arcane", - Email: new(email), - DisplayName: new(displayName), - PasswordHash: hashedPassword, - Roles: models.StringSlice{"admin"}, - RequiresPasswordChange: true, - } - - if err := tx.Create(userModel).Error; err != nil { - return fmt.Errorf("failed to create default admin user: %w", err) + // Users already exist — see if `arcane` is one of them. If not, + // someone removed the default admin on purpose and we leave it alone. + var existing models.User + err := tx.Where("username = ?", "arcane").First(&existing).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return fmt.Errorf("failed to look up default admin user: %w", err) } + adminUserID = existing.ID + return nil + }) + if err != nil { + return err + } - slog.InfoContext(ctx, "👑 Default admin user created!") - slog.InfoContext(ctx, "🔑 Username: arcane") - slog.InfoContext(ctx, "🔑 Password: arcane-admin") - slog.InfoContext(ctx, "⚠️ User will be prompted to change password on first login") - + if adminUserID == "" || s.roleService == nil { return nil + } + + // Step 2: ensure the default admin holds the global Admin role assignment. + // Idempotent — if the assignment already exists, SetUserAssignments is a + // no-op write (it dedupes via the unique index). This recovers users that + // were created by older builds before the role-grant on bootstrap was + // wired up. + assignments, err := s.roleService.ListUserAssignments(ctx, adminUserID) + if err != nil { + return fmt.Errorf("failed to list default admin assignments: %w", err) + } + for _, a := range assignments { + if a.RoleID == authz.BuiltInRoleAdmin && a.EnvironmentID == nil { + return nil // already has it + } + } + desired := append([]models.UserRoleAssignment{}, assignments...) + desired = append(desired, models.UserRoleAssignment{ + RoleID: authz.BuiltInRoleAdmin, + EnvironmentID: nil, }) + // Strip the source field so SetUserAssignments classifies everything as manual. + manual := make([]models.UserRoleAssignment, 0, len(desired)) + for _, a := range desired { + manual = append(manual, models.UserRoleAssignment{RoleID: a.RoleID, EnvironmentID: a.EnvironmentID}) + } + if err := s.roleService.SetUserAssignments(ctx, adminUserID, manual); err != nil { + return fmt.Errorf("failed to grant default admin global role: %w", err) + } + slog.InfoContext(ctx, "Default admin granted global Admin role assignment", "user_id", adminUserID) + return nil } func (s *UserService) DeleteUser(ctx context.Context, id string) error { + // Last-admin guard: if this user holds the only global Admin assignment, + // refuse the delete. Checked OUTSIDE the transaction because RoleService + // uses its own session — and the guard is informational only (the unique + // index in user_role_assignments wouldn't catch this cross-row condition). + if s.roleService != nil { + var holdsGlobalAdmin bool + assignments, err := s.roleService.ListUserAssignments(ctx, id) + if err == nil { + for _, a := range assignments { + if a.RoleID == authz.BuiltInRoleAdmin && a.EnvironmentID == nil { + holdsGlobalAdmin = true + break + } + } + } + if holdsGlobalAdmin { + remaining, err := s.roleService.CountGlobalAdminsExcludingUser(ctx, id) + if err != nil { + return err + } + if remaining == 0 { + return ErrCannotRemoveLastAdmin + } + } + } return dbutil.WithTx(ctx, s.db.DB, func(tx *gorm.DB) error { - var existing models.User if err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", id). - First(&existing).Error; err != nil { + First(&models.User{}).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrUserNotFound } return fmt.Errorf("failed to load user: %w", err) } - if userHasRoleInternal(existing.Roles, "admin") { - remainingAdmins, err := s.remainingAdminCountExcludingUserInternal(tx, id) - if err != nil { - return err - } - if remainingAdmins == 0 { - return ErrCannotRemoveLastAdmin - } - } - if err := tx.Delete(&models.User{}, "id = ?", id).Error; err != nil { return fmt.Errorf("failed to delete user: %w", err) } @@ -342,7 +403,9 @@ func (s *UserService) UpgradePasswordHash(ctx context.Context, userID, password func (s *UserService) ListUsersPaginated(ctx context.Context, params pagination.QueryParams) ([]user.User, pagination.Response, error) { var users []models.User - query := s.db.WithContext(ctx).Model(&models.User{}) + query := s.db.WithContext(ctx). + Model(&models.User{}). + Where("is_service_account = ?", false) if term := strings.TrimSpace(params.Search); term != "" { searchPattern := "%" + term + "%" @@ -357,55 +420,96 @@ func (s *UserService) ListUsersPaginated(ctx context.Context, params pagination. return nil, pagination.Response{}, fmt.Errorf("failed to paginate users: %w", err) } - result, err := s.toUserResponseDtosInternal(ctx, users) - if err != nil { - return nil, pagination.Response{}, err - } + result := s.toUserResponseDtosInternal(ctx, users) return result, paginationResp, nil } func (s *UserService) ToUserResponseDto(ctx context.Context, u models.User) (user.User, error) { - if !userHasRoleInternal(u.Roles, "admin") { - return toUserResponseDtoInternal(u, 0), nil - } - - adminCount, err := s.adminUserCountInternal(ctx) - if err != nil { - return user.User{}, err - } - - return toUserResponseDtoInternal(u, adminCount), nil + return s.toUserResponseDtoInternal(ctx, u), nil } -func (s *UserService) toUserResponseDtosInternal(ctx context.Context, users []models.User) ([]user.User, error) { - adminCount, err := s.adminUserCountInternal(ctx) - if err != nil { - return nil, err - } - +func (s *UserService) toUserResponseDtosInternal(ctx context.Context, users []models.User) []user.User { result := make([]user.User, len(users)) for i, u := range users { - result[i] = toUserResponseDtoInternal(u, adminCount) + result[i] = s.toUserResponseDtoInternal(ctx, u) } - - return result, nil + return result } -func toUserResponseDtoInternal(u models.User, adminCount int) user.User { - return user.User{ +// toUserResponseDtoInternal builds the public User DTO. RoleAssignments and +// PermissionsByEnv come from the RBAC service. CanDelete is false when this +// user holds the only global Admin assignment (i.e. removing them would orphan +// the instance). +func (s *UserService) toUserResponseDtoInternal(ctx context.Context, u models.User) user.User { + dto := user.User{ ID: u.ID, Username: u.Username, DisplayName: u.DisplayName, Email: u.Email, - Roles: u.Roles, - CanDelete: !userHasRoleInternal(u.Roles, "admin") || adminCount > 1, + CanDelete: true, OidcSubjectId: u.OidcSubjectId, Locale: u.Locale, RequiresPasswordChange: u.RequiresPasswordChange, CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05.999999Z"), UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05.999999Z"), + RoleAssignments: []user.RoleAssignmentSummary{}, + PermissionsByEnv: map[string][]string{}, + } + if s.roleService == nil { + return dto + } + if rows, err := s.roleService.ListUserAssignments(ctx, u.ID); err == nil { + dto.RoleAssignments = make([]user.RoleAssignmentSummary, len(rows)) + for i, r := range rows { + dto.RoleAssignments[i] = user.RoleAssignmentSummary{ + RoleID: r.RoleID, + EnvironmentID: r.EnvironmentID, + Source: r.Source, + } + // Last-admin guard: this user is non-deletable if they hold the + // only global Admin assignment. + if r.RoleID == authz.BuiltInRoleAdmin && r.EnvironmentID == nil { + if remaining, cerr := s.roleService.CountGlobalAdminsExcludingUser(ctx, u.ID); cerr == nil && remaining == 0 { + dto.CanDelete = false + } + } + } + } + if ps, err := s.roleService.ResolvePermissions(ctx, &u); err == nil && ps != nil { + dto.PermissionsByEnv = permissionSetToMap(ps) } + return dto +} + +// permissionSetToMap flattens a PermissionSet into the wire format consumed +// by the frontend: a map from environment ID (or "global") to a list of +// permission strings. Sudo callers expose "*" under "global" as a sentinel +// meaning "every permission". +func permissionSetToMap(ps *authz.PermissionSet) map[string][]string { + out := map[string][]string{} + if ps == nil { + return out + } + if ps.Sudo { + out["global"] = []string{"*"} + return out + } + if len(ps.Global) > 0 { + globals := make([]string, 0, len(ps.Global)) + for p := range ps.Global { + globals = append(globals, p) + } + out["global"] = globals + } + for envID, perms := range ps.PerEnv { + list := make([]string, 0, len(perms)) + for p := range perms { + list = append(list, p) + } + out[envID] = list + } + return out } func (s *UserService) GetUser(ctx context.Context, userID string) (*models.User, error) { @@ -422,49 +526,3 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go Error return &user, err } - -func (s *UserService) remainingAdminCountExcludingUserInternal(tx *gorm.DB, excludedUserID string) (int, error) { - var count int64 - if err := s.adminUsersScopeInternal(tx.Model(&models.User{}).Where("id <> ?", excludedUserID)).Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count remaining admin users: %w", err) - } - - return int(count), nil -} - -func userHasRoleInternal(roles models.StringSlice, role string) bool { - for _, currentRole := range roles { - if strings.EqualFold(currentRole, role) { - return true - } - } - - return false -} - -func (s *UserService) adminUserCountInternal(ctx context.Context) (int, error) { - var count int64 - if err := s.adminUsersScopeInternal(s.db.WithContext(ctx).Model(&models.User{})).Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count admin users: %w", err) - } - - return int(count), nil -} - -func (s *UserService) adminUsersScopeInternal(query *gorm.DB) *gorm.DB { - switch s.db.Name() { - case "sqlite": - return query.Where( - "EXISTS (SELECT 1 FROM json_each(users.roles) WHERE lower(json_each.value) = ?)", - "admin", - ) - case "postgres": - return query.Where( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(users.roles::jsonb) AS role WHERE lower(role) = ?)", - "admin", - ) - default: - slog.Warn("Using LIKE-based admin role query fallback for unsupported database dialect", "dialect", s.db.Name()) - return query.Where("LOWER(roles) LIKE ?", `%\"admin\"%`) - } -} diff --git a/backend/internal/services/user_service_test.go b/backend/internal/services/user_service_test.go index 7d0e1da1dd..3b43c7b8ec 100644 --- a/backend/internal/services/user_service_test.go +++ b/backend/internal/services/user_service_test.go @@ -5,117 +5,98 @@ import ( "testing" "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/authz" "github.com/getarcaneapp/arcane/backend/pkg/pagination" "github.com/stretchr/testify/require" ) -func createTestUser(t *testing.T, svc *UserService, id, username string, roles models.StringSlice) *models.User { +// setupUserAndRoleServices wires both services together the way bootstrap +// does, so the legacy-admin-guard tests exercise the real RBAC path. +func setupUserAndRoleServices(t *testing.T) (*UserService, *RoleService) { t.Helper() + db := setupAuthServiceTestDB(t) + role := NewRoleService(db) + require.NoError(t, role.EnsureBuiltInRoles(context.Background())) + user := NewUserService(db).WithRoleService(role) + return user, role +} - user := &models.User{ +func createTestUser(t *testing.T, svc *UserService, id, username string) *models.User { + t.Helper() + created, err := svc.CreateUser(context.Background(), &models.User{ BaseModel: models.BaseModel{ID: id}, Username: username, - Roles: roles, - } - - created, err := svc.CreateUser(context.Background(), user) + }) require.NoError(t, err) - return created } +// grantGlobalAdmin assigns the built-in Admin role globally to the user. +func grantGlobalAdmin(t *testing.T, role *RoleService, userID string) { + t.Helper() + require.NoError(t, role.SetUserAssignments(context.Background(), userID, []models.UserRoleAssignment{ + {RoleID: authz.BuiltInRoleAdmin, EnvironmentID: nil}, + })) +} + func TestDeleteUserRejectsDeletingOnlyAdmin(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - admin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"Admin"}) + admin := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, admin.ID) - err := svc.DeleteUser(ctx, admin.ID) + err := userSvc.DeleteUser(ctx, admin.ID) require.ErrorIs(t, err, ErrCannotRemoveLastAdmin) - stillThere, err := svc.GetUserByID(ctx, admin.ID) + stillThere, err := userSvc.GetUserByID(ctx, admin.ID) require.NoError(t, err) require.Equal(t, admin.ID, stillThere.ID) } func TestDeleteUserAllowsDeletingNonAdmin(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"admin"}) - nonAdmin := createTestUser(t, svc, "user-1", "user", models.StringSlice{"user"}) + admin := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, admin.ID) + nonAdmin := createTestUser(t, userSvc, "user-1", "user") - err := svc.DeleteUser(ctx, nonAdmin.ID) + err := userSvc.DeleteUser(ctx, nonAdmin.ID) require.NoError(t, err) - _, err = svc.GetUserByID(ctx, nonAdmin.ID) + _, err = userSvc.GetUserByID(ctx, nonAdmin.ID) require.ErrorIs(t, err, ErrUserNotFound) } func TestDeleteUserAllowsDeletingAdminWhenAnotherAdminExists(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - adminToDelete := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"Admin"}) - createTestUser(t, svc, "admin-2", "backup", models.StringSlice{"admin"}) + adminToDelete := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, adminToDelete.ID) + backup := createTestUser(t, userSvc, "admin-2", "backup") + grantGlobalAdmin(t, roleSvc, backup.ID) - err := svc.DeleteUser(ctx, adminToDelete.ID) + err := userSvc.DeleteUser(ctx, adminToDelete.ID) require.NoError(t, err) - _, err = svc.GetUserByID(ctx, adminToDelete.ID) + _, err = userSvc.GetUserByID(ctx, adminToDelete.ID) require.ErrorIs(t, err, ErrUserNotFound) } -func TestUpdateUserRejectsRemovingAdminFromOnlyAdmin(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) - ctx := context.Background() - - admin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"ADMIN"}) - admin.Roles = models.StringSlice{"user"} - - _, err := svc.UpdateUser(ctx, admin) - require.ErrorIs(t, err, ErrCannotRemoveLastAdmin) - - persisted, err := svc.GetUserByID(ctx, admin.ID) - require.NoError(t, err) - require.Equal(t, models.StringSlice{"ADMIN"}, persisted.Roles) -} - -func TestUpdateUserAllowsRemovingAdminWhenAnotherAdminExists(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) - ctx := context.Background() - - admin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"admin"}) - createTestUser(t, svc, "admin-2", "backup", models.StringSlice{"ADMIN"}) - - admin.Roles = models.StringSlice{"user"} - - updated, err := svc.UpdateUser(ctx, admin) - require.NoError(t, err) - require.Equal(t, models.StringSlice{"user"}, updated.Roles) - - persisted, err := svc.GetUserByID(ctx, admin.ID) - require.NoError(t, err) - require.Equal(t, models.StringSlice{"user"}, persisted.Roles) -} - func TestListUsersPaginatedSetsCanDeleteFromGlobalAdminCount(t *testing.T) { - db := setupAuthServiceTestDB(t) - svc := NewUserService(db) + userSvc, roleSvc := setupUserAndRoleServices(t) ctx := context.Background() - lastAdmin := createTestUser(t, svc, "admin-1", "arcane", models.StringSlice{"admin"}) - nonAdmin := createTestUser(t, svc, "user-1", "user", models.StringSlice{"user"}) + lastAdmin := createTestUser(t, userSvc, "admin-1", "arcane") + grantGlobalAdmin(t, roleSvc, lastAdmin.ID) + nonAdmin := createTestUser(t, userSvc, "user-1", "user") - users, _, err := svc.ListUsersPaginated(ctx, pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, - SortParams: pagination.SortParams{Sort: "Username", Order: pagination.SortOrder("asc")}, - Filters: map[string]string{}, + users, _, err := userSvc.ListUsersPaginated(ctx, pagination.QueryParams{ + Params: pagination.Params{Start: 0, Limit: 20}, + SortParams: pagination.SortParams{Sort: "Username", Order: pagination.SortOrder("asc")}, + Filters: map[string]string{}, }) require.NoError(t, err) require.Len(t, users, 2) diff --git a/backend/internal/services/version_service.go b/backend/internal/services/version_service.go index b508597f91..e164803a4b 100644 --- a/backend/internal/services/version_service.go +++ b/backend/internal/services/version_service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + ref "github.com/distribution/reference" "github.com/getarcaneapp/arcane/backend/buildables" "github.com/getarcaneapp/arcane/backend/internal/config" docker "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" @@ -19,7 +20,6 @@ import ( "github.com/getarcaneapp/arcane/types/version" containertypes "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" - ref "go.podman.io/image/v5/docker/reference" "golang.org/x/mod/semver" ) @@ -43,9 +43,10 @@ type VersionService struct { revision string containerRegistryService *ContainerRegistryService dockerService *DockerClientService + imageUpdateService *ImageUpdateService } -func NewVersionService(httpClient *http.Client, disabled bool, version string, revision string, containerRegistryService *ContainerRegistryService, dockerService *DockerClientService) *VersionService { +func NewVersionService(httpClient *http.Client, disabled bool, version string, revision string, containerRegistryService *ContainerRegistryService, dockerService *DockerClientService, imageUpdateService *ImageUpdateService) *VersionService { if httpClient == nil { httpClient = http.DefaultClient } @@ -57,6 +58,7 @@ func NewVersionService(httpClient *http.Client, disabled bool, version string, r revision: revision, containerRegistryService: containerRegistryService, dockerService: dockerService, + imageUpdateService: imageUpdateService, } } @@ -70,7 +72,7 @@ func (s *VersionService) getLatestReleaseInternal(ctx context.Context) (latestRe return latestRelease{}, fmt.Errorf("create GitHub request: %w", err) } - resp, err := s.httpClient.Do(req) //nolint:gosec // intentional request to fixed GitHub releases API endpoint + resp, err := s.httpClient.Do(req) if err != nil { return latestRelease{}, fmt.Errorf("get latest release: %w", err) } @@ -89,7 +91,7 @@ func (s *VersionService) getLatestReleaseInternal(ctx context.Context) (latestRe return latestRelease{}, fmt.Errorf("decode payload: %w", err) } if payload.TagName == "" { - return latestRelease{}, fmt.Errorf("GitHub API returned empty tag name") + return latestRelease{}, errors.New("GitHub API returned empty tag name") } return latestRelease{ @@ -99,7 +101,7 @@ func (s *VersionService) getLatestReleaseInternal(ctx context.Context) (latestRe }, nil }) - if staleErr, ok := errors.AsType[*cache.ErrStale](err); ok { + if staleErr, ok := errors.AsType[*cache.StaleError](err); ok { slog.Warn("Failed to fetch latest release, returning stale cache", "error", staleErr.Err) return rel, nil } @@ -176,7 +178,7 @@ func (s *VersionService) GetVersionInformation(ctx context.Context, currentVersi latest, err := s.GetLatestVersion(ctx) if err != nil { - if staleErr, ok := errors.AsType[*cache.ErrStale](err); ok { + if staleErr, ok := errors.AsType[*cache.StaleError](err); ok { slog.Warn("Failed to refresh latest version; using stale cache", "error", staleErr.Err) } else { return check, err @@ -207,7 +209,7 @@ func (s *VersionService) isSemverVersion() bool { func (s *VersionService) getDisplayVersion() string { version := strings.TrimPrefix(strings.TrimSpace(s.version), "v") if strings.Contains(strings.ToLower(version), "next") && s.revision != "" && s.revision != "unknown" { - return fmt.Sprintf("next-%s", config.ShortRevision()) + return "next-" + config.ShortRevision() } if s.isSemverVersion() { return "v" + version @@ -221,7 +223,7 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { ver := s.normalizeVersion(s.version) // Always detect current image info - currentTag, currentDigest, currentImageRef := s.detectCurrentImageInfo(ctx) + currentTag, currentDigest, currentImageRef, currentImageID := s.detectCurrentImageInfo(ctx) // Build base info struct (always populated) info := &version.Info{ @@ -243,26 +245,25 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { return info } + semverUpdateAvailable := false + // For semver versions, check GitHub releases if isSemver { rel, err := s.getLatestReleaseInternal(ctx) - var staleErr *cache.ErrStale + var staleErr *cache.StaleError if err == nil || errors.As(err, &staleErr) { if rel.TagName != "" { info.NewestVersion = rel.TagName - info.UpdateAvailable = s.IsNewer(rel.TagName, ver) + semverUpdateAvailable = s.IsNewer(rel.TagName, ver) info.ReleaseURL = s.ReleaseURL(rel.TagName) info.ReleaseNotes = rel.Body info.ReleasedAt = rel.PublishedAt } } - return info } - // For non-semver versions (like "next"), check digest-based updates - if currentTag != "" && currentDigest != "" && currentImageRef != "" && s.containerRegistryService != nil { - updateAvailable, latestDigest := s.checkDigestBasedUpdate(ctx, currentTag, currentDigest, currentImageRef) - info.UpdateAvailable = updateAvailable + digestUpdateAvailable, latestDigest := s.storedOrDigestBasedUpdateInternal(ctx, currentImageID, currentTag, currentDigest, currentImageRef) + if latestDigest != "" { info.NewestDigest = latestDigest } @@ -278,9 +279,27 @@ func (s *VersionService) GetAppVersionInfo(ctx context.Context) *version.Info { } } + info.UpdateAvailable = semverUpdateAvailable || (!isSemver && digestUpdateAvailable) return info } +func (s *VersionService) storedOrDigestBasedUpdateInternal(ctx context.Context, currentImageID, currentTag, currentDigest, currentImageRef string) (bool, string) { + if s.imageUpdateService != nil && strings.TrimSpace(currentImageID) != "" { + record, found, err := s.imageUpdateService.getStoredUpdateByImageIDInternal(ctx, currentImageID) + if err != nil { + slog.WarnContext(ctx, "Failed to read stored Arcane image update state", "imageID", currentImageID, "error", err) + } else if found { + return record.HasUpdate, stringPtrToString(record.LatestDigest) + } + } + + if currentTag != "" && currentDigest != "" && currentImageRef != "" && s.containerRegistryService != nil { + return s.checkDigestBasedUpdate(ctx, currentTag, currentDigest, currentImageRef) + } + + return false, "" +} + func parseEnabledFeatures() []string { raw := strings.TrimSpace(buildables.EnabledFeatures) if raw == "" { @@ -304,44 +323,50 @@ func parseEnabledFeatures() []string { } // detectCurrentImageInfo attempts to detect the current container's image tag and digest -func (s *VersionService) detectCurrentImageInfo(ctx context.Context) (tag string, digest string, imageRef string) { +func (s *VersionService) detectCurrentImageInfo(ctx context.Context) (tag string, digest string, imageRef string, imageID string) { if s.dockerService == nil { slog.Debug("detectCurrentImageInfo: dockerService is nil") - return "", "", "" + return "", "", "", "" } dockerClient, err := s.dockerService.GetClient(ctx) if err != nil { slog.Debug("detectCurrentImageInfo: failed to get docker client", "error", err) - return "", "", "" + return "", "", "", "" } containerId := s.detectContainerID(ctx, dockerClient) if containerId == "" { slog.Debug("detectCurrentImageInfo: could not detect container ID") - return "", "", "" + return "", "", "", "" } slog.Debug("detectCurrentImageInfo: detected container", "containerId", containerId) inspectResult, err := libarcane.ContainerInspectWithCompatibility(ctx, dockerClient, containerId, client.ContainerInspectOptions{}) if err != nil { slog.Debug("detectCurrentImageInfo: failed to inspect container", "containerId", containerId, "error", err) - return "", "", "" + return "", "", "", "" } container := inspectResult.Container + imageID = container.Image + + configImage := "" + if container.Config != nil { + configImage = container.Config.Image + } // Parse tag from container config image (user-specified reference) - tag = s.extractTagFromImageRef(container.Config.Image) + tag = s.extractTagFromImageRef(configImage) // Get digest and normalized imageRef from container image imageRef, digest = s.extractImageDetails(ctx, dockerClient, container) // Fallback to container config image if RepoDigests didn't provide imageRef if imageRef == "" { - imageRef = s.normalizeImageRef(container.Config.Image) + imageRef = s.normalizeImageRef(configImage) } - return tag, digest, imageRef + return tag, digest, imageRef, imageID } // detectContainerID tries to get the current container ID, falling back to label-based detection diff --git a/backend/internal/services/version_service_test.go b/backend/internal/services/version_service_test.go new file mode 100644 index 0000000000..f80bcd8584 --- /dev/null +++ b/backend/internal/services/version_service_test.go @@ -0,0 +1,108 @@ +package services + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/models" + libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" + "github.com/moby/moby/api/types/container" + dockertypesimage "github.com/moby/moby/api/types/image" + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionService_GetAppVersionInfoDoesNotUseStoredDigestUpdateForSemverBuildInternal(t *testing.T) { + ctx := context.Background() + db := setupImageUpdateTestDB(t) + + const ( + containerID = "arcane-container-1234567890" + imageID = "sha256:arcane-image" + imageRef = "ghcr.io/getarcaneapp/arcane:latest" + ) + currentDigest := digest.FromString("current-arcane").String() + latestDigest := digest.FromString("latest-arcane").String() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/containers/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode([]container.Summary{ + { + ID: containerID, + State: container.StateRunning, + Labels: map[string]string{ + libupdater.LabelArcane: "true", + }, + }, + })) + case strings.Contains(r.URL.Path, "/containers/") && strings.HasSuffix(r.URL.Path, "/json"): + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(container.InspectResponse{ + ID: containerID, + Image: imageID, + Config: &container.Config{ + Image: imageRef, + Labels: map[string]string{ + libupdater.LabelArcane: "true", + }, + }, + })) + case strings.Contains(r.URL.Path, "/images/") && strings.HasSuffix(r.URL.Path, "/json"): + encodedRef := strings.TrimSuffix(r.URL.Path[strings.LastIndex(r.URL.Path, "/images/")+len("/images/"):], "/json") + _, err := url.PathUnescape(encodedRef) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(dockertypesimage.InspectResponse{ + ID: imageID, + RepoTags: []string{imageRef}, + RepoDigests: []string{"ghcr.io/getarcaneapp/arcane@" + currentDigest}, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + require.NoError(t, db.WithContext(ctx).Create(&models.ImageUpdateRecord{ + ID: imageID, + Repository: "ghcr.io/getarcaneapp/arcane", + Tag: "latest", + HasUpdate: true, + UpdateType: models.UpdateTypeDigest, + CurrentVersion: "latest", + CurrentDigest: ¤tDigest, + LatestDigest: &latestDigest, + CheckTime: time.Now().UTC(), + }).Error) + + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("unavailable")), + Request: req, + }, nil + })} + dockerService := &DockerClientService{client: newTestDockerClient(t, server)} + imageUpdateService := NewImageUpdateService(db, nil, nil, dockerService, nil, nil, nil) + svc := NewVersionService(httpClient, false, "1.2.3", "revision", nil, dockerService, imageUpdateService) + + info := svc.GetAppVersionInfo(ctx) + + require.NotNil(t, info) + assert.True(t, info.IsSemverVersion) + assert.False(t, info.UpdateAvailable) + assert.Equal(t, latestDigest, info.NewestDigest) + assert.Equal(t, currentDigest, info.CurrentDigest) +} diff --git a/backend/internal/services/volume_service.go b/backend/internal/services/volume_service.go index de7940122c..4fd5d90915 100644 --- a/backend/internal/services/volume_service.go +++ b/backend/internal/services/volume_service.go @@ -5,6 +5,7 @@ import ( "bytes" "compress/gzip" "context" + "errors" "fmt" "io" "log/slog" @@ -445,7 +446,7 @@ func (s *VolumeService) getHelperImageInternal(ctx context.Context, dockerClient pullErr = pullImageErr slog.WarnContext(ctx, "volume service: failed to pull tools helper image, attempting arcane fallback", "error", pullImageErr.Error()) } else { - pullErr = fmt.Errorf("image service unavailable") + pullErr = errors.New("image service unavailable") slog.WarnContext(ctx, "volume service: image service unavailable, attempting arcane fallback") } @@ -496,7 +497,7 @@ func resolveBackupStorageMountFromMountsInternal(mounts []container.MountPoint, }, true } -func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, dockerClient *client.Client, target string, readOnly bool) (backupStorageMountInternal, error) { +func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, dockerClient *client.Client, target string, readOnly bool) backupStorageMountInternal { if dockerClient != nil { containerID := s.getArcaneContainerIDInternal(ctx, dockerClient) if containerID != "" { @@ -504,7 +505,7 @@ func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, d if err != nil { slog.WarnContext(ctx, "volume service: failed to inspect arcane container for backup mount resolution, falling back to named volume", "container_id", containerID, "error", err.Error()) } else if resolved, ok := resolveBackupStorageMountFromMountsInternal(inspect.Container.Mounts, target, readOnly); ok { - return resolved, nil + return resolved } } } @@ -518,14 +519,11 @@ func (s *VolumeService) resolveBackupStorageMountInternal(ctx context.Context, d ReadOnly: readOnly, }, requiresEnsure: true, - }, nil + } } func (s *VolumeService) resolveUsableBackupStorageMountInternal(ctx context.Context, dockerClient *client.Client, target string, readOnly bool) (backupStorageMountInternal, error) { - backupStorage, err := s.resolveBackupStorageMountInternal(ctx, dockerClient, target, readOnly) - if err != nil { - return backupStorageMountInternal{}, err - } + backupStorage := s.resolveBackupStorageMountInternal(ctx, dockerClient, target, readOnly) if backupStorage.requiresEnsure { if err := s.ensureBackupVolumeInternal(ctx); err != nil { return backupStorageMountInternal{}, err @@ -668,6 +666,7 @@ func (s *VolumeService) createBackupTempContainerInternal(ctx context.Context, d type cleanupReadCloser struct { io.Reader io.Closer + cleanup func() } @@ -834,15 +833,17 @@ func (s *VolumeService) CleanupHelperContainers(ctx context.Context) { } } -func (s *VolumeService) CleanupOrphanedVolumeHelpers(ctx context.Context) error { +func (s *VolumeService) CleanupOrphanedVolumeHelpers(ctx context.Context) (int, error) { + slog.DebugContext(ctx, "volume service: cleanup orphaned volume helper containers") + dockerClient, err := s.dockerService.GetClient(ctx) if err != nil { - return fmt.Errorf("failed to get docker client for orphan helper cleanup: %w", err) + return 0, fmt.Errorf("failed to get docker client for orphan helper cleanup: %w", err) } containers, err := dockerClient.ContainerList(ctx, client.ContainerListOptions{All: true}) if err != nil { - return fmt.Errorf("failed to list containers for orphan helper cleanup: %w", err) + return 0, fmt.Errorf("failed to list containers for orphan helper cleanup: %w", err) } removedCount := 0 @@ -852,15 +853,19 @@ func (s *VolumeService) CleanupOrphanedVolumeHelpers(ctx context.Context) error } if _, err := dockerClient.ContainerRemove(ctx, c.ID, volumeHelperRemoveOptionsInternal()); err != nil { - slog.WarnContext(ctx, "failed to remove orphaned volume helper container", "container_id", c.ID, "error", err.Error()) + slog.WarnContext(ctx, + "volume service: failed to remove orphaned volume helper container", + "container_id", c.ID, + "container_names", c.Names, + "error", err.Error(), + ) continue } removedCount++ } - slog.InfoContext(ctx, "volume service: orphan helper cleanup completed", "removed_count", removedCount) - return nil + return removedCount, nil } func (s *VolumeService) removeHelperEntry(volumeName string) { @@ -930,7 +935,7 @@ func (s *VolumeService) DeleteFile(ctx context.Context, volumeName, filePath str } // Prevent deleting root if sanitizedPath == "/" { - return fmt.Errorf("cannot delete root directory") + return errors.New("cannot delete root directory") } containerID, cleanup, err := s.createTempContainerInternal(ctx, volumeName, false) @@ -1116,7 +1121,7 @@ func (s *VolumeService) CreateBackup(ctx context.Context, volumeName string, use } hostConfig := s.buildHelperHostConfigInternal(helperImage, []string{ - fmt.Sprintf("%s:/volume:ro", volumeName), + volumeName + ":/volume:ro", }, []mount.Mount{backupStorage.mount}) resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ @@ -1363,7 +1368,7 @@ func (s *VolumeService) RestoreBackup(ctx context.Context, volumeName, backupID } hostConfig := s.buildHelperHostConfigInternal(helperImage, []string{ - fmt.Sprintf("%s:/volume", volumeName), + volumeName + ":/volume", }, []mount.Mount{backupStorage.mount}) resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ @@ -1408,7 +1413,7 @@ func (s *VolumeService) RestoreBackup(ctx context.Context, volumeName, backupID func (s *VolumeService) sanitizeBackupPathInternal(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { - return "", fmt.Errorf("invalid path: empty") + return "", errors.New("invalid path: empty") } cleaned := path.Clean(trimmed) if cleaned == "." || cleaned == "/" { @@ -1429,7 +1434,7 @@ func (s *VolumeService) sanitizeBackupIDInternal(backupID string) (string, error return "", fmt.Errorf("invalid backup id: %w", err) } if strings.Contains(cleaned, "/") { - return "", fmt.Errorf("invalid backup id: path separators not allowed") + return "", errors.New("invalid backup id: path separators not allowed") } return cleaned, nil } @@ -1440,7 +1445,7 @@ func (s *VolumeService) backupArchiveFilenameInternal(backupID string) (string, return "", err } - return fmt.Sprintf("%s.tar.gz", sanitizedBackupID), nil + return sanitizedBackupID + ".tar.gz", nil } // sanitizeBrowsePath validates and cleans a path for file browser operations. @@ -1457,11 +1462,11 @@ func (s *VolumeService) sanitizeBrowsePathInternal(input string) (string, error) } // Check for path traversal attempts if strings.Contains(cleaned, "/../") || strings.HasSuffix(cleaned, "/..") || cleaned == "/.." { - return "", fmt.Errorf("invalid path: path traversal not allowed") + return "", errors.New("invalid path: path traversal not allowed") } // After cleaning, the path should not escape root if !strings.HasPrefix(cleaned, "/") { - return "", fmt.Errorf("invalid path: must be absolute") + return "", errors.New("invalid path: must be absolute") } return cleaned, nil } @@ -1592,7 +1597,7 @@ func (s *VolumeService) ListBackupFiles(ctx context.Context, backupID string) ([ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, backupID string, paths []string, user models.User) error { slog.DebugContext(ctx, "volume service: restore backup files", "volume", volumeName, "backup_id", backupID, "paths_count", len(paths), "user", user.ID) if len(paths) == 0 { - return fmt.Errorf("no paths provided") + return errors.New("no paths provided") } filename, err := s.backupArchiveFilenameInternal(backupID) if err != nil { @@ -1604,7 +1609,7 @@ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, back return err } if backup.VolumeName != volumeName { - return fmt.Errorf("backup does not belong to volume") + return errors.New("backup does not belong to volume") } // Create pre-restore backup for safety (consistent with RestoreBackup behavior) @@ -1623,7 +1628,7 @@ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, back cleanedPaths = append(cleanedPaths, cleaned) } if len(cleanedPaths) == 0 { - return fmt.Errorf("no valid paths provided") + return errors.New("no valid paths provided") } tarPaths := make([]string, 0, len(cleanedPaths)) @@ -1654,7 +1659,7 @@ func (s *VolumeService) RestoreBackupFiles(ctx context.Context, volumeName, back } hostConfig := s.buildHelperHostConfigInternal(helperImage, []string{ - fmt.Sprintf("%s:/volume", volumeName), + volumeName + ":/volume", }, []mount.Mount{backupStorage.mount}) resp, err := dockerClient.ContainerCreate(ctx, client.ContainerCreateOptions{ @@ -1754,7 +1759,7 @@ func (s *VolumeService) UploadAndRestore(ctx context.Context, volumeName string, } defer func() { _ = tmpFile.Close() - _ = os.Remove(tmpFile.Name()) //nolint:gosec // temp file path is generated by os.CreateTemp + _ = os.Remove(tmpFile.Name()) }() if _, err := io.Copy(tmpFile, archive); err != nil { return fmt.Errorf("failed to buffer upload: %w", err) @@ -2203,7 +2208,7 @@ func (s *VolumeService) downloadFileFromContainerInternal( if hdr.FileInfo().IsDir() { _ = reader.Close() cleanup() - return nil, 0, fmt.Errorf("path is a directory") + return nil, 0, errors.New("path is a directory") } return &cleanupReadCloser{ diff --git a/backend/internal/services/volume_service_test.go b/backend/internal/services/volume_service_test.go index 7749162afb..5076d8f01c 100644 --- a/backend/internal/services/volume_service_test.go +++ b/backend/internal/services/volume_service_test.go @@ -250,8 +250,7 @@ func TestResolveBackupStorageMountFromMountsInternal(t *testing.T) { func TestResolveBackupStorageMountInternalFallsBackToNamedVolume(t *testing.T) { svc := &VolumeService{backupVolumeName: "arcane-backups"} - got, err := svc.resolveBackupStorageMountInternal(context.Background(), nil, "/backups", true) - require.NoError(t, err) + got := svc.resolveBackupStorageMountInternal(context.Background(), nil, "/backups", true) require.Equal(t, backupStorageModeNamedVolumeFallback, got.mode) require.Equal(t, mount.TypeVolume, got.mount.Type) require.Equal(t, "arcane-backups", got.mount.Source) diff --git a/backend/internal/services/vulnerability_service.go b/backend/internal/services/vulnerability_service.go index ef7ae244de..45fe897ef5 100644 --- a/backend/internal/services/vulnerability_service.go +++ b/backend/internal/services/vulnerability_service.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -26,14 +27,17 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" dockerutils "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" + activitylib "github.com/getarcaneapp/arcane/backend/pkg/libarcane/activity" libupdater "github.com/getarcaneapp/arcane/backend/pkg/libarcane/imageupdate" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/timeouts" "github.com/getarcaneapp/arcane/backend/pkg/pagination" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/getarcaneapp/arcane/types/vulnerability" "github.com/moby/moby/api/pkg/stdcopy" containertypes "github.com/moby/moby/api/types/container" imagetypes "github.com/moby/moby/api/types/image" mounttypes "github.com/moby/moby/api/types/mount" + dockerregistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -64,6 +68,9 @@ const ( DefaultTrivyDBRepository = "ghcr.io/getarcaneapp/trivy-db:2" DefaultTrivyJavaDBRepository = "ghcr.io/getarcaneapp/trivy-java-db:1" DefaultTrivyChecksBundleRepository = "ghcr.io/getarcaneapp/trivy-checks:1" + trivyRegistryConfigDir = "/tmp/arcane-registry-auth" + trivyRegistryConfigCopyDest = "/tmp" + trivyRegistryConfigTarName = "arcane-registry-auth/config.json" ) type trivyRuntimeOptions struct { @@ -79,6 +86,8 @@ type VulnerabilityService struct { eventService *EventService settingsService *SettingsService notificationService *NotificationService + activityService *ActivityService + registryService *ContainerRegistryService // scanLocks provides per-image locking to allow concurrent scans of different images // while preventing duplicate scans of the same image scanLocks sync.Map // map[string]*sync.Mutex @@ -93,7 +102,10 @@ type VulnerabilityService struct { // getImageLock returns a mutex for the given image ID, creating one if needed func (s *VulnerabilityService) getImageLock(imageID string) *sync.Mutex { lock, _ := s.scanLocks.LoadOrStore(imageID, &sync.Mutex{}) - return lock.(*sync.Mutex) + if mu, ok := lock.(*sync.Mutex); ok { + return mu + } + return &sync.Mutex{} } func (s *VulnerabilityService) setScanPhaseInternal(imageID string, phase vulnerability.ScanPhase) { @@ -230,20 +242,72 @@ func (s *VulnerabilityService) getTrivyScanSlotChannelInternal(limit int) chan i } // NewVulnerabilityService creates a new VulnerabilityService instance -func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService, eventService *EventService, settingsService *SettingsService, notificationService *NotificationService) *VulnerabilityService { +func NewVulnerabilityService(db *database.DB, dockerService *DockerClientService, eventService *EventService, settingsService *SettingsService, notificationService *NotificationService, activityService *ActivityService, registryService *ContainerRegistryService) *VulnerabilityService { return &VulnerabilityService{ db: db, dockerService: dockerService, eventService: eventService, settingsService: settingsService, notificationService: notificationService, + activityService: activityService, + registryService: registryService, trivyScanSlots: nil, } } +func buildDockerConfigJSONInternal(authConfigs map[string]dockerregistry.AuthConfig) ([]byte, error) { + if len(authConfigs) == 0 { + return nil, nil + } + + type authEntry struct { + Auth string `json:"auth"` + } + + auths := make(map[string]authEntry, len(authConfigs)) + for host, cfg := range authConfigs { + host = strings.TrimSpace(host) + if host == "" || cfg.Username == "" || cfg.Password == "" { + continue + } + + auths[host] = authEntry{ + Auth: base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.Password)), + } + } + + if len(auths) == 0 { + return nil, nil + } + + return json.Marshal(struct { + Auths map[string]authEntry `json:"auths"` + }{Auths: auths}) +} + +func (s *VulnerabilityService) buildTrivyRegistryConfigInternal(ctx context.Context) []byte { + if s.registryService == nil { + return nil + } + + authConfigs, err := s.registryService.GetAllRegistryAuthConfigs(ctx) + if err != nil { + slog.WarnContext(ctx, "failed to load registry credentials for trivy scan; continuing anonymously", "error", err) + return nil + } + + configJSON, err := buildDockerConfigJSONInternal(authConfigs) + if err != nil { + slog.WarnContext(ctx, "failed to build trivy registry config; continuing anonymously", "error", err) + return nil + } + + return configJSON +} + // ScanImage scans an image for vulnerabilities using Trivy func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imageID string, user models.User) (*vulnerability.ScanResult, error) { - scanCtx := context.WithoutCancel(ctx) + scanCtx := utils.ActivityRuntimeContext(ctx, nil) // Get Docker client to inspect the image dockerClient, err := s.dockerService.GetClient(ctx) @@ -271,17 +335,22 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag "imageId", imageID, "imageName", imageName, ) + activityID := s.startVulnerabilityScanActivityInternal(scanCtx, envID, imageID, imageName, &user) + // Make the scan cancelable: cancelling the activity cancels scanCtx, which + // unblocks the Trivy container wait and triggers its (cancel-safe) teardown. + scanCtx = s.activityService.Track(scanCtx, activityID) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) // Create pending scan record pendingResult := &vulnerability.ScanResult{ - ImageID: imageID, - ImageName: imageName, - ScanTime: time.Now(), - Status: vulnerability.ScanStatusScanning, + ImageID: imageID, + ImageName: imageName, + ScanTime: time.Now(), + Status: vulnerability.ScanStatusScanning, + ActivityID: utils.StringPtrFromTrimmed(activityID), } s.applyScanPhaseToResultInternal(pendingResult) - if saveErr := s.saveScanResultWithRetryInternal(scanCtx, pendingResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(scanCtx, pendingResult); saveErr != nil { slog.WarnContext(scanCtx, "failed to save pending scan result", "error", saveErr, @@ -297,22 +366,22 @@ func (s *VulnerabilityService) ScanImage(ctx context.Context, envID string, imag ) } - go func(bgCtx context.Context, scanEnvID, imgID, imgName string, scanUser models.User) { - s.scanImageInBackgroundInternal(bgCtx, scanEnvID, imgID, imgName, scanUser) - }(scanCtx, envID, imageID, imageName, user) + go func(bgCtx context.Context, scanEnvID, imgID, imgName string, scanUser models.User, scanActivityID string) { + s.scanImageInBackgroundInternal(bgCtx, scanEnvID, imgID, imgName, scanUser, scanActivityID) + }(scanCtx, envID, imageID, imageName, user, activityID) return pendingResult, nil } -func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record *models.VulnerabilityScanRecord) bool { +func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record *models.VulnerabilityScanRecord) { if record == nil { - return false + return } if record.Status != models.ScanStatusScanning && record.Status != models.ScanStatusPending { - return false + return } if time.Since(record.ScanTime) <= scanStaleTimeout { - return false + return } errMsg := "Scan timed out or was interrupted. Please retry." @@ -322,7 +391,7 @@ func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record s.clearScanPhaseInternal(record.ID) if s.db == nil { - return true + return } if err := s.db.WithContext(ctx). @@ -334,15 +403,14 @@ func (s *VulnerabilityService) markStaleScanIfNeeded(ctx context.Context, record "duration": record.Duration, }).Error; err != nil { slog.WarnContext(ctx, "failed to mark stale scan as failed", "error", err, "scan_id", record.ID) - return false + return } - - return true } -func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context, envID string, imageID, imageName string, user models.User) { +func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context, envID string, imageID, imageName string, user models.User, activityID string) { defer s.clearScanPhaseInternal(imageID) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseCreatingContainer, 10, "Preparing vulnerability scanner") slog.DebugContext(ctx, "starting background vulnerability scan", @@ -354,14 +422,16 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context trivyImage, err := s.ensureTrivyImageInternal(ctx) if err != nil { s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Unable to prepare vulnerability scanner") result := &vulnerability.ScanResult{ - ImageID: imageID, - ImageName: imageName, - ScanTime: time.Now(), - Status: vulnerability.ScanStatusFailed, - Error: fmt.Sprintf("Trivy scanner is not available: %s", err.Error()), + ImageID: imageID, + ImageName: imageName, + ScanTime: time.Now(), + Status: vulnerability.ScanStatusFailed, + ActivityID: utils.StringPtrFromTrimmed(activityID), + Error: "Trivy scanner is not available: " + err.Error(), } - if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, result); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } slog.WarnContext(ctx, @@ -371,24 +441,29 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context "imageName", imageName, "error", result.Error, ) + s.completeVulnerabilityScanActivityInternal(ctx, activityID, false, result.Error) return } + s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseScanningImage) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseScanningImage, 45, "Scanning image for vulnerabilities") startTime := time.Now() result, err := s.runTrivyScan(ctx, trivyImage, imageName, imageID) duration := time.Since(startTime).Milliseconds() if err != nil { s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Storing failed scan result") failedResult := &vulnerability.ScanResult{ - ImageID: imageID, - ImageName: imageName, - ScanTime: time.Now(), - Status: vulnerability.ScanStatusFailed, - Error: err.Error(), - Duration: duration, - } - if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + ImageID: imageID, + ImageName: imageName, + ScanTime: time.Now(), + Status: vulnerability.ScanStatusFailed, + ActivityID: utils.StringPtrFromTrimmed(activityID), + Error: err.Error(), + Duration: duration, + } + if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult); saveErr != nil { slog.WarnContext(ctx, "failed to save failed scan result", "error", saveErr) } slog.WarnContext(ctx, @@ -400,13 +475,16 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context "error", err, ) s.logScanEvent(ctx, envID, imageID, imageName, user, false, err.Error()) + s.completeVulnerabilityScanActivityInternal(ctx, activityID, false, err.Error()) return } result.Duration = duration + result.ActivityID = utils.StringPtrFromTrimmed(activityID) s.ensureSummary(result) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseStoringResults) - if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + s.updateVulnerabilityScanActivityInternal(ctx, activityID, vulnerability.ScanPhaseStoringResults, 90, "Storing vulnerability results") + if saveErr := s.saveScanResultWithRetryInternal(ctx, result); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } @@ -420,6 +498,72 @@ func (s *VulnerabilityService) scanImageInBackgroundInternal(ctx context.Context ) s.logScanEvent(ctx, envID, imageID, imageName, user, true, "") + s.completeVulnerabilityScanActivityInternal(ctx, activityID, true, "") +} + +func (s *VulnerabilityService) startVulnerabilityScanActivityInternal(ctx context.Context, envID, imageID, imageName string, user *models.User) string { + if s.activityService == nil { + return "" + } + + activity, err := s.activityService.StartActivity(ctx, StartActivityRequest{ + EnvironmentID: envID, + Type: models.ActivityTypeVulnerabilityScan, + ResourceType: new("image"), + ResourceID: &imageID, + ResourceName: &imageName, + StartedBy: user, + Progress: new(0), + Step: string(vulnerability.ScanPhaseCreatingContainer), + LatestMessage: "Vulnerability scan queued", + }) + if err != nil { + slog.WarnContext(ctx, "failed to create vulnerability scan activity", "error", err, "imageId", imageID) + return "" + } + return activity.ID +} + +func (s *VulnerabilityService) updateVulnerabilityScanActivityInternal(ctx context.Context, activityID string, phase vulnerability.ScanPhase, progress int, message string) { + if s.activityService == nil || activityID == "" { + return + } + + if _, err := s.activityService.AppendMessage(ctx, activityID, AppendActivityMessageRequest{ + Level: models.ActivityMessageLevelInfo, + Message: message, + Progress: &progress, + Step: string(phase), + }); err != nil { + slog.DebugContext(ctx, "failed to append vulnerability scan activity message", "activityId", activityID, "error", err) + } +} + +func (s *VulnerabilityService) completeVulnerabilityScanActivityInternal(ctx context.Context, activityID string, success bool, errMessage string) { + if s.activityService == nil || activityID == "" { + return + } + + status := models.ActivityStatusSuccess + message := "Vulnerability scan completed" + var errorPtr *string + if !success { + if activitylib.CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + message = "Vulnerability scan cancelled" + } else { + status = models.ActivityStatusFailed + message = "Vulnerability scan failed" + if strings.TrimSpace(errMessage) != "" { + errorPtr = &errMessage + message = errMessage + } + } + } + + if _, err := s.activityService.CompleteActivity(utils.ActivityRuntimeContext(ctx, nil), activityID, status, message, errorPtr); err != nil { + slog.DebugContext(ctx, "failed to complete vulnerability scan activity", "activityId", activityID, "error", err) + } } // GetScanResult retrieves the most recent scan result for an image @@ -1149,7 +1293,7 @@ func (s *VulnerabilityService) saveScheduledFailedScanInternal( Error: scanErr.Error(), Duration: duration, } - if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, failedResult); saveErr != nil { slog.WarnContext(ctx, "failed to save failed scan result", "error", saveErr) } s.logScheduledScanEvent(ctx, envID, imageID, imageName, user, false, scanErr.Error()) @@ -1164,7 +1308,7 @@ func (s *VulnerabilityService) saveScheduledSuccessfulScanInternal( ) { result.Duration = duration s.ensureSummary(result) - if saveErr := s.saveScanResultWithRetryInternal(ctx, result, defaultScanSaveRetryAttempts, defaultScanSaveRetryDelay); saveErr != nil { + if saveErr := s.saveScanResultWithRetryInternal(ctx, result); saveErr != nil { slog.WarnContext(ctx, "failed to save scan result", "error", saveErr) } s.logScheduledScanEvent(ctx, envID, imageID, imageName, user, true, "") @@ -1216,7 +1360,7 @@ func (s *VulnerabilityService) sendScheduledVulnerabilitySummaryInternal( summaryDate := time.Now().UTC().Format("2006-01-02") payload := VulnerabilityNotificationPayload{ - CVEID: fmt.Sprintf("Daily Summary - %s", summaryDate), + CVEID: "Daily Summary - " + summaryDate, Severity: fmt.Sprintf("Critical:%d High:%d Medium:%d Low:%d Unknown:%d", summary.severityCounts["CRITICAL"], summary.severityCounts["HIGH"], summary.severityCounts["MEDIUM"], summary.severityCounts["LOW"], summary.severityCounts["UNKNOWN"]), ImageName: fmt.Sprintf("%d image(s) scanned, %d with fixable vulnerabilities", scanned, summary.imagesWithFixable), FixedVersion: fmt.Sprintf("%d fixable vulnerability record(s)", summary.totalFixable), @@ -1352,10 +1496,18 @@ func (s *VulnerabilityService) createBatchTrivyContainer(ctx context.Context, tr return "", err } + registryConfigJSON := s.buildTrivyRegistryConfigInternal(ctx) + env := append([]string(nil), runtimeOptions.ContainerEnv...) + if len(registryConfigJSON) > 0 { + env = append(env, "DOCKER_CONFIG="+trivyRegistryConfigDir) + // ECR tokens are refreshed when the batch container is created; a multi-hour + // batch could outlive the token and fall back to anonymous pulls. + } + config := &containertypes.Config{ Image: trivyImage, Entrypoint: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, - Env: append([]string(nil), runtimeOptions.ContainerEnv...), + Env: env, Labels: map[string]string{ libarcane.InternalResourceLabel: "true", }, @@ -1367,7 +1519,7 @@ func (s *VulnerabilityService) createBatchTrivyContainer(ctx context.Context, tr resources, cpuSet, applyLimits := s.getTrivyContainerResourceOptionsInternal(ctx, dockerClient) applyTrivyContainerResourcesInternal(hostConfig, resources, cpuSet, applyLimits) - containerID, err := s.createAndStartTrivyContainerInternal(ctx, dockerClient, "batch", config, hostConfig) + containerID, err := s.createAndStartTrivyContainerInternal(ctx, dockerClient, "batch", config, hostConfig, registryConfigJSON) if err != nil { return "", err } @@ -1401,7 +1553,8 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con outputPath := newTrivyOutputPathInternal() defer cleanupTrivyOutputFileInContainerInternal(ctx, dockerClient, containerID, outputPath) - execCmd := []string{"trivy", "image", "--format", "json", "--quiet", "--output", outputPath, "--timeout", trivyTimeout} + execCmd := make([]string, 0, 16) + execCmd = append(execCmd, "trivy", "image", "--format", "json", "--quiet", "--output", outputPath, "--timeout", trivyTimeout) execCmd = append(execCmd, trivyScanCacheBackendArgsInternal()...) execCmd = append(execCmd, trivyDefaultRepositoryArgsInternal()...) execCmd = append(execCmd, imageName) @@ -1435,9 +1588,9 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con } if execInspect.ExitCode != 0 { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { - errMsg = readTempFileExcerptInternal(stdoutFile, trivyErrorExcerptSize) + errMsg = readTempFileExcerptInternal(stdoutFile) } if errMsg == "" { errMsg = fmt.Sprintf("exit status %d", execInspect.ExitCode) @@ -1465,7 +1618,7 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con } if err != nil { if errors.Is(err, io.EOF) { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -1478,7 +1631,7 @@ func (s *VulnerabilityService) execTrivyScanInContainer(ctx context.Context, con } if trivyReport == nil { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -1648,7 +1801,7 @@ func (s *VulnerabilityService) GetTrivyVersion(ctx context.Context) string { return "" } - return parseTrivyVersion(readTempFileExcerptInternal(stdoutFile, trivyErrorExcerptSize)) + return parseTrivyVersion(readTempFileExcerptInternal(stdoutFile)) } func parseTrivyVersion(output string) string { @@ -2069,17 +2222,17 @@ func (s *VulnerabilityService) ensureTrivyCacheVolumeInternal(ctx context.Contex } // runTrivyScan executes Trivy scan on an image -func (s *VulnerabilityService) getTrivyConfigFiles() (configContent, ignoreContent string, err error) { +func (s *VulnerabilityService) getTrivyConfigFiles() (configContent, ignoreContent string) { if s.settingsService == nil { - return "", "", nil + return "", "" } cfg := s.settingsService.GetSettingsConfig() if cfg == nil { - return "", "", nil + return "", "" } - return cfg.TrivyConfig.Value, cfg.TrivyIgnore.Value, nil + return cfg.TrivyConfig.Value, cfg.TrivyIgnore.Value } func (s *VulnerabilityService) createTrivyConfigTempFile(ctx context.Context, content string) (string, bool) { @@ -2091,7 +2244,7 @@ func (s *VulnerabilityService) createTrivyConfigTempFile(ctx context.Context, co if _, err := configFile.WriteString(content); err != nil { slog.WarnContext(ctx, "failed to write trivy config", "error", err) _ = configFile.Close() - _ = os.Remove(configFile.Name()) //nolint:gosec // temp file path is generated by os.CreateTemp + _ = os.Remove(configFile.Name()) return "", false } _ = configFile.Close() @@ -2107,7 +2260,7 @@ func (s *VulnerabilityService) createTrivyIgnoreTempFile(ctx context.Context, co if _, err := ignoreFile.WriteString(content); err != nil { slog.WarnContext(ctx, "failed to write trivy ignore", "error", err) _ = ignoreFile.Close() - _ = os.Remove(ignoreFile.Name()) //nolint:gosec // temp file path is generated by os.CreateTemp + _ = os.Remove(ignoreFile.Name()) return "", false } _ = ignoreFile.Close() @@ -2344,7 +2497,7 @@ func cleanupTrivyLogTempFilesInternal(ctx context.Context, files ...*os.File) { continue } - if err := os.Remove(path); err != nil { //nolint:gosec // temp file path comes from os.CreateTemp + if err := os.Remove(path); err != nil { slog.WarnContext(ctx, "failed to remove trivy temp file", "path", path, "error", err) } } @@ -2484,12 +2637,46 @@ func removeDockerContainerInternal(ctx context.Context, dockerClient *client.Cli } } +func copyRegistryConfigToContainerInternal(ctx context.Context, dockerClient *client.Client, containerID string, configJSON []byte) error { + if dockerClient == nil || containerID == "" || len(configJSON) == 0 { + return nil + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + if err := tw.WriteHeader(&tar.Header{Name: "arcane-registry-auth/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{Name: trivyRegistryConfigTarName, Mode: 0o644, Size: int64(len(configJSON))}); err != nil { + _ = tw.Close() + return err + } + if _, err := tw.Write(configJSON); err != nil { + _ = tw.Close() + return err + } + if err := tw.Close(); err != nil { + return err + } + + copyCtx, copyCancel := timeouts.WithTimeout(ctx, 0, timeouts.DefaultDockerAPI) + defer copyCancel() + + _, err := dockerClient.CopyToContainer(copyCtx, containerID, client.CopyToContainerOptions{ + DestinationPath: trivyRegistryConfigCopyDest, + Content: &buf, + }) + return err +} + func (s *VulnerabilityService) createAndStartTrivyContainerInternal( ctx context.Context, dockerClient *client.Client, scope string, config *containertypes.Config, hostConfig *containertypes.HostConfig, + registryConfigJSON []byte, ) (string, error) { logTrivyContainerStartupRequestInternal(ctx, scope, config, hostConfig) @@ -2505,6 +2692,19 @@ func (s *VulnerabilityService) createAndStartTrivyContainerInternal( return "", fmt.Errorf("failed to create container: %w", err) } + if len(registryConfigJSON) > 0 { + if err := copyRegistryConfigToContainerInternal(ctx, dockerClient, resp.ID, registryConfigJSON); err != nil { + // DOCKER_CONFIG may still point at the target directory. When the + // config file is absent, Trivy falls back to anonymous registry access. + slog.WarnContext(ctx, + "failed to inject registry credentials into trivy container; continuing anonymously", + "scope", scope, + "containerId", resp.ID, + "error", err, + ) + } + } + startCtx, startCancel := timeouts.WithTimeout(ctx, 0, timeouts.DefaultDockerAPI) defer startCancel() @@ -2698,7 +2898,7 @@ func tempFileSizeInternal(file *os.File) int64 { return stat.Size() } -func readTempFileExcerptInternal(file *os.File, maxBytes int64) string { +func readTempFileExcerptInternal(file *os.File) string { if file == nil { return "" } @@ -2707,12 +2907,7 @@ func readTempFileExcerptInternal(file *os.File, maxBytes int64) string { return "" } - reader := io.Reader(file) - if maxBytes > 0 { - reader = io.LimitReader(file, maxBytes) - } - - content, err := io.ReadAll(reader) + content, err := io.ReadAll(io.LimitReader(file, trivyErrorExcerptSize)) if err != nil { return "" } @@ -3043,6 +3238,7 @@ func (s *VulnerabilityService) runTrivyContainer( imageID string, config *containertypes.Config, hostConfig *containertypes.HostConfig, + registryConfigJSON []byte, ) (*os.File, *os.File, int64, int64, string, error) { stdoutFile, err := createTrivyLogTempFileInternal("trivy-stdout-*") if err != nil { @@ -3064,7 +3260,7 @@ func (s *VulnerabilityService) runTrivyContainer( } }() - containerID, err = s.createAndStartTrivyContainerInternal(ctx, dockerClient, "single-scan", config, hostConfig) + containerID, err = s.createAndStartTrivyContainerInternal(ctx, dockerClient, "single-scan", config, hostConfig, registryConfigJSON) if err != nil { return nil, nil, 0, 0, "", fmt.Errorf("failed to prepare trivy scan container: %w", err) } @@ -3223,10 +3419,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri } // Get Trivy config files if they exist - configContent, ignoreContent, err := s.getTrivyConfigFiles() - if err != nil { - slog.WarnContext(ctx, "failed to get trivy config files", "error", err) - } + configContent, ignoreContent := s.getTrivyConfigFiles() cacheDir := s.getTrivySingleScanCacheDirForSlotInternal(slotID) if cacheDir != "" { @@ -3248,7 +3441,13 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri return nil, err } - config := buildTrivyContainerConfig(trivyImage, cmdArgs, runtimeOptions.ContainerEnv) + registryConfigJSON := s.buildTrivyRegistryConfigInternal(ctx) + env := append([]string(nil), runtimeOptions.ContainerEnv...) + if len(registryConfigJSON) > 0 { + env = append(env, "DOCKER_CONFIG="+trivyRegistryConfigDir) + } + + config := buildTrivyContainerConfig(trivyImage, cmdArgs, env) resources, cpuSet, applyLimits := s.getTrivyContainerResourceOptionsInternal(ctx, dockerClient) securityOpts, privileged := s.getTrivyRuntimeSecurityInternal() hostConfig := buildTrivyHostConfig( @@ -3263,7 +3462,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri ) s.setScanPhaseInternal(imageID, vulnerability.ScanPhaseCreatingContainer) - stdoutFile, stderrFile, duration, statusCode, containerID, err := s.runTrivyContainer(ctx, dockerClient, imageID, config, hostConfig) + stdoutFile, stderrFile, duration, statusCode, containerID, err := s.runTrivyContainer(ctx, dockerClient, imageID, config, hostConfig, registryConfigJSON) if err != nil { return nil, err } @@ -3271,9 +3470,9 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri defer removeDockerContainerInternal(ctx, dockerClient, containerID, "failed to cleanup trivy scan container") if statusCode != 0 { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { - errMsg = readTempFileExcerptInternal(stdoutFile, trivyErrorExcerptSize) + errMsg = readTempFileExcerptInternal(stdoutFile) } if errMsg == "" { errMsg = fmt.Sprintf("exit status %d", statusCode) @@ -3300,7 +3499,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri } if err != nil { if errors.Is(err, io.EOF) { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -3313,7 +3512,7 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri } if trivyReport == nil { - errMsg := readTempFileExcerptInternal(stderrFile, trivyErrorExcerptSize) + errMsg := readTempFileExcerptInternal(stderrFile) if errMsg == "" { errMsg = "trivy scan produced no output" } @@ -3344,15 +3543,11 @@ func (s *VulnerabilityService) runTrivyScan(ctx context.Context, trivyImage stri func (s *VulnerabilityService) saveScanResultWithRetryInternal( ctx context.Context, result *vulnerability.ScanResult, - maxAttempts int, - retryDelay time.Duration, ) error { - if maxAttempts <= 0 { - maxAttempts = 1 - } - if retryDelay < 0 { - retryDelay = 0 - } + const ( + maxAttempts = defaultScanSaveRetryAttempts + retryDelay = defaultScanSaveRetryDelay + ) var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { diff --git a/backend/internal/services/vulnerability_service_test.go b/backend/internal/services/vulnerability_service_test.go index 6f5e9c2d80..8e68fc29ad 100644 --- a/backend/internal/services/vulnerability_service_test.go +++ b/backend/internal/services/vulnerability_service_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -21,10 +22,81 @@ import ( containertypes "github.com/moby/moby/api/types/container" mounttypes "github.com/moby/moby/api/types/mount" networktypes "github.com/moby/moby/api/types/network" + dockerregistry "github.com/moby/moby/api/types/registry" "github.com/stretchr/testify/require" "gorm.io/gorm" ) +func TestBuildDockerConfigJSONInternal(t *testing.T) { + tests := []struct { + name string + auths map[string]dockerregistry.AuthConfig + wantAuths map[string]string + }{ + { + name: "nil map", + }, + { + name: "empty map", + auths: map[string]dockerregistry.AuthConfig{}, + }, + { + name: "one registry", + auths: map[string]dockerregistry.AuthConfig{ + "registry.example.com": {Username: "user", Password: "pass"}, + }, + wantAuths: map[string]string{ + "registry.example.com": base64.StdEncoding.EncodeToString([]byte("user:pass")), + }, + }, + { + name: "skips blank credentials", + auths: map[string]dockerregistry.AuthConfig{ + "registry.example.com": {Username: "user", Password: "pass"}, + "blank-user.example": {Username: "", Password: "pass"}, + "blank-pass.example": {Username: "user", Password: ""}, + " ": {Username: "user", Password: "pass"}, + }, + wantAuths: map[string]string{ + "registry.example.com": base64.StdEncoding.EncodeToString([]byte("user:pass")), + }, + }, + { + name: "two registries", + auths: map[string]dockerregistry.AuthConfig{ + "registry-a.example.com": {Username: "user-a", Password: "pass-a"}, + "registry-b.example.com": {Username: "user-b", Password: "pass-b"}, + }, + wantAuths: map[string]string{ + "registry-a.example.com": base64.StdEncoding.EncodeToString([]byte("user-a:pass-a")), + "registry-b.example.com": base64.StdEncoding.EncodeToString([]byte("user-b:pass-b")), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildDockerConfigJSONInternal(tt.auths) + require.NoError(t, err) + if len(tt.wantAuths) == 0 { + require.Nil(t, got) + return + } + + var parsed struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + require.NoError(t, json.Unmarshal(got, &parsed)) + require.Len(t, parsed.Auths, len(tt.wantAuths)) + for host, want := range tt.wantAuths { + require.Equal(t, want, parsed.Auths[host].Auth) + } + }) + } +} + func TestIsExpectedDockerStreamEndErrorInternal(t *testing.T) { tests := []struct { name string @@ -932,7 +1004,7 @@ func TestVulnerabilityService_ListAllVulnerabilities_FiltersIgnoredInline(t *tes require.NoError(t, db.Create(&ignore).Error) items, page, err := svc.ListAllVulnerabilities(ctx, "env-1", pagination.QueryParams{ - PaginationParams: pagination.PaginationParams{Start: 0, Limit: 20}, + Params: pagination.Params{Start: 0, Limit: 20}, }) require.NoError(t, err) require.Empty(t, items) diff --git a/backend/internal/services/webhook_service.go b/backend/internal/services/webhook_service.go index 52bab3f8b6..570be01da5 100644 --- a/backend/internal/services/webhook_service.go +++ b/backend/internal/services/webhook_service.go @@ -202,7 +202,7 @@ func (s *WebhookService) CreateWebhook(ctx context.Context, name, targetType, ac _, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeWebhookCreate, Severity: models.EventSeveritySuccess, - Title: fmt.Sprintf("Webhook created: %s", wh.Name), + Title: "Webhook created: " + wh.Name, Description: fmt.Sprintf("Created webhook '%s' targeting %s (%s)", wh.Name, wh.TargetType, wh.ActionType), ResourceType: new("webhook"), ResourceID: &wh.ID, @@ -337,7 +337,7 @@ func (s *WebhookService) DeleteWebhook(ctx context.Context, id, environmentID st _, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeWebhookDelete, Severity: models.EventSeverityInfo, - Title: fmt.Sprintf("Webhook deleted: %s", wh.Name), + Title: "Webhook deleted: " + wh.Name, Description: fmt.Sprintf("Deleted webhook '%s'", wh.Name), ResourceType: new("webhook"), ResourceID: &wh.ID, @@ -372,7 +372,7 @@ func (s *WebhookService) UpdateWebhook(ctx context.Context, id, environmentID st _, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{ Type: models.EventTypeWebhookUpdate, Severity: models.EventSeveritySuccess, - Title: fmt.Sprintf("Webhook updated: %s", wh.Name), + Title: "Webhook updated: " + wh.Name, Description: fmt.Sprintf("Updated webhook '%s' enabled=%v", wh.Name, enabled), ResourceType: new("webhook"), ResourceID: &wh.ID, @@ -430,7 +430,7 @@ func (s *WebhookService) TriggerByToken(ctx context.Context, rawToken string) (* // Record trigger time — best-effort, do not fail the request if this update fails. now := time.Now() - _ = s.db.WithContext(ctx).Model(wh).Update("last_triggered_at", now).Error //nolint:errcheck + _ = s.db.WithContext(ctx).Model(wh).Update("last_triggered_at", now).Error s.logWebhookEventInternal(ctx, wh, actionType, models.EventSeveritySuccess, "") @@ -550,7 +550,7 @@ func (s *WebhookService) syncWebhookContainerTargetInternal(ctx context.Context, return } - _ = s.db.WithContext(ctx).Model(wh).Updates(updates).Error //nolint:errcheck + _ = s.db.WithContext(ctx).Model(wh).Updates(updates).Error } func (s *WebhookService) executeProjectWebhookActionInternal(ctx context.Context, wh *models.Webhook, actionType string) (*updater.Result, error) { @@ -620,9 +620,9 @@ func (s *WebhookService) logWebhookEventInternal(ctx context.Context, wh *models if s.eventService == nil { return } - title := fmt.Sprintf("Webhook triggered: %s", wh.Name) + title := "Webhook triggered: " + wh.Name if severity == models.EventSeverityError { - title = fmt.Sprintf("Webhook trigger failed: %s", wh.Name) + title = "Webhook trigger failed: " + wh.Name } description := fmt.Sprintf("Target type: %s, action: %s", wh.TargetType, actionType) if errMsg != "" { diff --git a/backend/pkg/authz/catalog.go b/backend/pkg/authz/catalog.go new file mode 100644 index 0000000000..d69251cb00 --- /dev/null +++ b/backend/pkg/authz/catalog.go @@ -0,0 +1,220 @@ +package authz + +const ( + PermissionScopeGlobal = "global" + PermissionScopeEnv = "env" +) + +// PermissionCatalogResource describes a resource group in the permission +// catalog. It is the authz-owned source for permission ordering, scope, and +// display metadata used by API manifests and validation helpers. +type PermissionCatalogResource struct { + Key string + Label string + Scope string + Actions []PermissionCatalogAction +} + +// PermissionCatalogAction describes one recognized permission. +type PermissionCatalogAction struct { + Key string + Permission string + Label string + Description string +} + +var permissionCatalog = []PermissionCatalogResource{ + {"users", "Users", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermUsersList, "List", ""}, + {"read", PermUsersRead, "Read", ""}, + {"create", PermUsersCreate, "Create", ""}, + {"update", PermUsersUpdate, "Update", ""}, + {"delete", PermUsersDelete, "Delete", ""}, + }}, + {"roles", "Roles", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermRolesList, "List", ""}, + {"read", PermRolesRead, "Read", ""}, + }}, + {"apikeys", "API Keys", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermApiKeysList, "List", ""}, + {"read", PermApiKeysRead, "Read", ""}, + {"create", PermApiKeysCreate, "Create", ""}, + {"update", PermApiKeysUpdate, "Update", ""}, + {"delete", PermApiKeysDelete, "Delete", ""}, + }}, + {"federated", "Federated Credentials", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermFederatedList, "List", ""}, + {"read", PermFederatedRead, "Read", ""}, + {"create", PermFederatedCreate, "Create", ""}, + {"update", PermFederatedUpdate, "Update", ""}, + {"delete", PermFederatedDelete, "Delete", ""}, + }}, + {"settings", "Settings", PermissionScopeGlobal, []PermissionCatalogAction{ + {"read", PermSettingsRead, "Read", ""}, + {"write", PermSettingsWrite, "Write", ""}, + }}, + {"environments", "Environments", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermEnvironmentsList, "List", ""}, + {"read", PermEnvironmentsRead, "Read", ""}, + {"create", PermEnvironmentsCreate, "Create", ""}, + {"update", PermEnvironmentsUpdate, "Update", ""}, + {"delete", PermEnvironmentsDelete, "Delete", ""}, + {"pair", PermEnvironmentsPair, "Pair agent", ""}, + {"sync", PermEnvironmentsSync, "Sync heartbeat", ""}, + }}, + {"registries", "Container Registries", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermRegistriesList, "List", ""}, + {"read", PermRegistriesRead, "Read", ""}, + {"create", PermRegistriesCreate, "Create", ""}, + {"update", PermRegistriesUpdate, "Update", ""}, + {"delete", PermRegistriesDelete, "Delete", ""}, + {"test", PermRegistriesTest, "Test", ""}, + }}, + {"templates", "Templates", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermTemplatesList, "List", ""}, + {"read", PermTemplatesRead, "Read", ""}, + {"create", PermTemplatesCreate, "Create", ""}, + {"update", PermTemplatesUpdate, "Update", ""}, + {"delete", PermTemplatesDelete, "Delete", ""}, + }}, + {"git-repositories", "Git Repositories", PermissionScopeGlobal, []PermissionCatalogAction{ + {"list", PermGitReposList, "List", ""}, + {"read", PermGitReposRead, "Read", ""}, + {"create", PermGitReposCreate, "Create", ""}, + {"update", PermGitReposUpdate, "Update", ""}, + {"delete", PermGitReposDelete, "Delete", ""}, + {"test", PermGitReposTest, "Test", ""}, + {"sync", PermGitReposSync, "Sync", ""}, + }}, + {"events", "Events", PermissionScopeGlobal, []PermissionCatalogAction{ + {"read", PermEventsRead, "Read", ""}, + }}, + {"customize", "Customize", PermissionScopeGlobal, []PermissionCatalogAction{ + {"manage", PermCustomizeManage, "Manage", ""}, + }}, + {"diagnostics", "Diagnostics", PermissionScopeGlobal, []PermissionCatalogAction{ + {"read", PermDiagnosticsRead, "View", "View runtime diagnostics, pprof profiles, and backend logs"}, + }}, + {"containers", "Containers", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermContainersList, "List", ""}, + {"read", PermContainersRead, "Read", ""}, + {"logs", PermContainersLogs, "View logs", ""}, + {"create", PermContainersCreate, "Create", ""}, + {"start", PermContainersStart, "Start", ""}, + {"stop", PermContainersStop, "Stop", ""}, + {"restart", PermContainersRestart, "Restart", ""}, + {"redeploy", PermContainersRedeploy, "Redeploy", ""}, + {"delete", PermContainersDelete, "Delete", ""}, + {"exec", PermContainersExec, "Exec / terminal", ""}, + {"autoupdate", PermContainersAutoUpdate, "Auto-update", ""}, + }}, + {"projects", "Projects", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermProjectsList, "List", ""}, + {"read", PermProjectsRead, "Read", ""}, + {"logs", PermProjectsLogs, "View logs", ""}, + {"create", PermProjectsCreate, "Create", ""}, + {"update", PermProjectsUpdate, "Update", ""}, + {"deploy", PermProjectsDeploy, "Deploy", ""}, + {"down", PermProjectsDown, "Bring down", ""}, + {"restart", PermProjectsRestart, "Restart", ""}, + {"delete", PermProjectsDelete, "Delete", ""}, + {"archive", PermProjectsArchive, "Archive / unarchive", ""}, + }}, + {"images", "Images", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermImagesList, "List", ""}, + {"read", PermImagesRead, "Read", ""}, + {"pull", PermImagesPull, "Pull", ""}, + {"push", PermImagesPush, "Push", ""}, + {"build", PermImagesBuild, "Build", ""}, + {"prune", PermImagesPrune, "Prune", ""}, + {"delete", PermImagesDelete, "Delete", ""}, + {"upload", PermImagesUpload, "Upload", ""}, + }}, + {"volumes", "Volumes", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermVolumesList, "List", ""}, + {"read", PermVolumesRead, "Read", ""}, + {"create", PermVolumesCreate, "Create", ""}, + {"delete", PermVolumesDelete, "Delete", ""}, + {"prune", PermVolumesPrune, "Prune", ""}, + {"browse", PermVolumesBrowse, "Browse", ""}, + {"upload", PermVolumesUpload, "Upload", ""}, + {"backup", PermVolumesBackup, "Backup / restore", ""}, + }}, + {"networks", "Networks", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermNetworksList, "List", ""}, + {"read", PermNetworksRead, "Read", ""}, + {"create", PermNetworksCreate, "Create", ""}, + {"delete", PermNetworksDelete, "Delete", ""}, + {"prune", PermNetworksPrune, "Prune", ""}, + }}, + {"swarm", "Swarm", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermSwarmRead, "Read", ""}, + {"init", PermSwarmInit, "Initialize", ""}, + {"join", PermSwarmJoin, "Join", ""}, + {"leave", PermSwarmLeave, "Leave", ""}, + {"spec", PermSwarmSpec, "Update spec", ""}, + {"nodes", PermSwarmNodes, "Manage nodes", ""}, + {"services", PermSwarmServices, "Manage services", ""}, + {"services:logs", PermSwarmServicesLogs, "View service logs", ""}, + {"stacks", PermSwarmStacks, "Manage stacks", ""}, + {"configs", PermSwarmConfigs, "Manage configs", ""}, + {"secrets", PermSwarmSecrets, "Manage secrets", ""}, + {"unlock", PermSwarmUnlock, "Unlock / join tokens", ""}, + }}, + {"gitops", "GitOps Syncs", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermGitOpsList, "List", ""}, + {"read", PermGitOpsRead, "Read", ""}, + {"create", PermGitOpsCreate, "Create", ""}, + {"update", PermGitOpsUpdate, "Update", ""}, + {"delete", PermGitOpsDelete, "Delete", ""}, + {"sync", PermGitOpsSync, "Trigger sync", ""}, + {"lifecycle", PermGitOpsLifecycle, "Configure pre-deploy hooks", "Create or modify a sync's pre-deploy lifecycle hook, which runs an arbitrary container with host bind mounts and env on every sync"}, + }}, + {"webhooks", "Webhooks", PermissionScopeEnv, []PermissionCatalogAction{ + {"list", PermWebhooksList, "List", ""}, + {"create", PermWebhooksCreate, "Create", ""}, + {"update", PermWebhooksUpdate, "Update", ""}, + {"delete", PermWebhooksDelete, "Delete", ""}, + }}, + {"jobs", "Background Jobs", PermissionScopeEnv, []PermissionCatalogAction{ + {"manage", PermJobsManage, "Manage", ""}, + }}, + {"notifications", "Notifications", PermissionScopeEnv, []PermissionCatalogAction{ + {"manage", PermNotificationsManage, "Manage", ""}, + }}, + {"dashboard", "Dashboard", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermDashboardRead, "Read", ""}, + }}, + {"system", "System", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermSystemRead, "Read", ""}, + {"prune", PermSystemPrune, "Prune", ""}, + {"upgrade", PermSystemUpgrade, "Trigger upgrade", ""}, + }}, + {"image-updates", "Image Updates", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermImageUpdatesRead, "Read", ""}, + {"check", PermImageUpdatesCheck, "Check", ""}, + }}, + {"vulnerabilities", "Vulnerabilities", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermVulnsRead, "Read", ""}, + {"scan", PermVulnsScan, "Scan", ""}, + {"manage", PermVulnsManage, "Manage ignores", ""}, + }}, + {"build-workspaces", "Build Workspaces", PermissionScopeEnv, []PermissionCatalogAction{ + {"manage", PermBuildWorkspacesManage, "Manage", ""}, + }}, + {"activities", "Activities", PermissionScopeEnv, []PermissionCatalogAction{ + {"read", PermActivitiesRead, "Read", ""}, + {"cancel", PermActivitiesCancel, "Cancel", ""}, + {"delete", PermActivitiesDelete, "Clear history", ""}, + }}, +} + +// PermissionCatalog returns a defensive copy of the full permission catalog. +func PermissionCatalog() []PermissionCatalogResource { + out := make([]PermissionCatalogResource, len(permissionCatalog)) + for i, resource := range permissionCatalog { + out[i] = resource + out[i].Actions = append([]PermissionCatalogAction(nil), resource.Actions...) + } + return out +} diff --git a/backend/pkg/authz/permission_set.go b/backend/pkg/authz/permission_set.go new file mode 100644 index 0000000000..ad7c96633e --- /dev/null +++ b/backend/pkg/authz/permission_set.go @@ -0,0 +1,128 @@ +package authz + +import "strings" + +// PermissionSet is the effective permission set for one caller in one request. +// Built once by the auth bridge and stashed in the request context. +// +// Global permissions apply to every environment and to org-level endpoints. +// PerEnv permissions apply only when the caller is acting on that specific +// environment. Sudo bypasses all checks (used for the agent token and the +// per-environment access token paths). +type PermissionSet struct { + Global map[string]struct{} + PerEnv map[string]map[string]struct{} + Sudo bool +} + +// Allows reports whether the caller may perform `perm`. For env-scoped +// permissions, envID is the target environment's ID. For org-level +// permissions, pass envID = "" — only Global permissions count. +func (ps *PermissionSet) Allows(perm, envID string) bool { + if ps == nil { + return false + } + if ps.Sudo { + return true + } + if _, ok := ps.Global[perm]; ok { + return true + } + if envID == "" { + return false + } + if env, ok := ps.PerEnv[envID]; ok { + if _, ok := env[perm]; ok { + return true + } + } + return false +} + +// IsGlobalAdmin reports whether the caller holds enough global permissions to +// be considered an administrator. True for sudo callers and for callers whose +// Global set contains every defined permission. Used by the backward-compat +// IsAdminFromContext helper and by last-admin guards. +// +// Implementation: every insertion path runs through +// services.validatePermissionsInternal → authz.IsKnownPermission, which does +// an exact-set check against AllPermissions(). ps.Global is therefore a true +// subset of AllPermissions, and equal cardinality implies equality — no +// per-call slice allocation or O(N) walk on the auth hot path. +func (ps *PermissionSet) IsGlobalAdmin() bool { + if ps == nil { + return false + } + if ps.Sudo { + return true + } + return len(ps.Global) >= TotalPermissionsCount() +} + +// SudoPermissionSet returns a PermissionSet that allows every action. Used for +// the agent token and environment access token paths, which bypass per-user +// permission resolution entirely. +func SudoPermissionSet() *PermissionSet { + return &PermissionSet{Sudo: true} +} + +// NewPermissionSet builds an empty PermissionSet ready for population. +func NewPermissionSet() *PermissionSet { + return &PermissionSet{ + Global: make(map[string]struct{}), + PerEnv: make(map[string]map[string]struct{}), + } +} + +// AddGlobal grants `perms` at global scope. +func (ps *PermissionSet) AddGlobal(perms ...string) { + if ps.Global == nil { + ps.Global = make(map[string]struct{}) + } + for _, p := range perms { + ps.Global[p] = struct{}{} + } +} + +// AddEnv grants `perms` scoped to envID. +func (ps *PermissionSet) AddEnv(envID string, perms ...string) { + if envID == "" { + ps.AddGlobal(perms...) + return + } + if ps.PerEnv == nil { + ps.PerEnv = make(map[string]map[string]struct{}) + } + env, ok := ps.PerEnv[envID] + if !ok { + env = make(map[string]struct{}) + ps.PerEnv[envID] = env + } + for _, p := range perms { + env[p] = struct{}{} + } +} + +// EnvIDFromPath extracts the environment ID from a Huma operation path of the +// form /environments/{id}/... Returns "" for paths without an env segment. +// Tolerates a leading /api prefix for safety, though the Huma group already +// strips it. +func EnvIDFromPath(path string) string { + path = strings.TrimPrefix(path, "/api") + if !strings.HasPrefix(path, "/environments/") { + return "" + } + rest := path[len("/environments/"):] + before, _, ok := strings.Cut(rest, "/") + if !ok { + // Path is just /environments/ (no trailing segment) — this is + // not an env-scoped operation, it's the env detail endpoint itself, + // which is org-level. + return "" + } + id := before + if id == "" { + return "" + } + return id +} diff --git a/backend/pkg/authz/permission_set_test.go b/backend/pkg/authz/permission_set_test.go new file mode 100644 index 0000000000..531daab258 --- /dev/null +++ b/backend/pkg/authz/permission_set_test.go @@ -0,0 +1,157 @@ +package authz + +import "testing" + +func TestPermissionSetAllowsGlobal(t *testing.T) { + ps := NewPermissionSet() + ps.AddGlobal(PermContainersList) + + if !ps.Allows(PermContainersList, "env-1") { + t.Fatal("global perm should apply to any env") + } + if !ps.Allows(PermContainersList, "") { + t.Fatal("global perm should apply org-level too") + } + if ps.Allows(PermContainersStart, "env-1") { + t.Fatal("unrelated perm should be denied") + } +} + +func TestPermissionSetEnvScopedDoesNotLeak(t *testing.T) { + ps := NewPermissionSet() + ps.AddEnv("env-1", PermContainersStart) + + if !ps.Allows(PermContainersStart, "env-1") { + t.Fatal("env perm should apply to its own env") + } + if ps.Allows(PermContainersStart, "env-2") { + t.Fatal("env perm must not leak to another env") + } + if ps.Allows(PermContainersStart, "") { + t.Fatal("env perm must not satisfy an org-level check") + } +} + +func TestSudoAllowsEverything(t *testing.T) { + ps := SudoPermissionSet() + if !ps.Allows(PermContainersDelete, "any-env") { + t.Fatal("sudo should allow any perm on any env") + } + if !ps.Allows(PermUsersDelete, "") { + t.Fatal("sudo should allow org-level perms") + } + if !ps.IsGlobalAdmin() { + t.Fatal("sudo should report as global admin") + } +} + +func TestEnvIDFromPath(t *testing.T) { + cases := map[string]string{ + "/environments/abc-123/containers": "abc-123", + "/environments/abc-123/containers/foo": "abc-123", + "/api/environments/abc-123/projects": "abc-123", + "/environments/abc-123": "", // org-level env detail + "/users": "", + "": "", + } + for input, want := range cases { + if got := EnvIDFromPath(input); got != want { + t.Errorf("EnvIDFromPath(%q) = %q, want %q", input, got, want) + } + } +} + +func TestIsOrgLevelAndEnvScoped(t *testing.T) { + if !IsOrgLevel(PermUsersList) { + t.Fatal("users:list should be org-level") + } + if IsEnvScoped(PermUsersList) { + t.Fatal("users:list should not be env-scoped") + } + if IsOrgLevel(PermContainersStart) { + t.Fatal("containers:start should not be org-level") + } + if !IsEnvScoped(PermContainersStart) { + t.Fatal("containers:start should be env-scoped") + } +} + +func TestIsKnownPermissionRejectsSyntheticPrefixMatches(t *testing.T) { + // Synthetic permissions whose prefix matches a known env-scoped family + // must not be accepted — otherwise an admin could inflate ps.Global past + // TotalPermissionsCount() with bogus entries and trip IsGlobalAdmin(). + for _, p := range []string{"containers:fake1", "projects:bogus", "images:made-up"} { + if IsKnownPermission(p) { + t.Errorf("IsKnownPermission(%q) = true, want false", p) + } + if IsEnvScoped(p) { + t.Errorf("IsEnvScoped(%q) = true, want false", p) + } + } +} + +func TestBuiltInRolesOnlyReferenceKnownPermissions(t *testing.T) { + for _, p := range BuiltInEditorPermissions() { + if !IsKnownPermission(p) { + t.Errorf("editor references unknown perm %q", p) + } + } + for _, p := range BuiltInDeployerPermissions() { + if !IsKnownPermission(p) { + t.Errorf("deployer references unknown perm %q", p) + } + } + for _, p := range BuiltInViewerPermissions() { + if !IsKnownPermission(p) { + t.Errorf("viewer references unknown perm %q", p) + } + } +} + +func TestPermissionCatalogDerivesKnownPermissionsAndScopes(t *testing.T) { + catalog := PermissionCatalog() + if len(catalog) == 0 { + t.Fatal("permission catalog must not be empty") + } + + all := AllPermissions() + if len(all) != TotalPermissionsCount() { + t.Fatalf("AllPermissions length = %d, TotalPermissionsCount = %d", len(all), TotalPermissionsCount()) + } + + seen := make(map[string]struct{}, len(all)) + var catalogCount int + for _, resource := range catalog { + if resource.Scope != PermissionScopeGlobal && resource.Scope != PermissionScopeEnv { + t.Fatalf("resource %q has invalid scope %q", resource.Key, resource.Scope) + } + for _, action := range resource.Actions { + catalogCount++ + if action.Permission == "" { + t.Fatalf("resource %q action %q has empty permission", resource.Key, action.Key) + } + if _, exists := seen[action.Permission]; exists { + t.Fatalf("duplicate permission %q in catalog", action.Permission) + } + seen[action.Permission] = struct{}{} + if !IsKnownPermission(action.Permission) { + t.Fatalf("catalog permission %q is not known", action.Permission) + } + if resource.Scope == PermissionScopeGlobal && !IsOrgLevel(action.Permission) { + t.Fatalf("catalog permission %q should be org-level", action.Permission) + } + if resource.Scope == PermissionScopeEnv && !IsEnvScoped(action.Permission) { + t.Fatalf("catalog permission %q should be env-scoped", action.Permission) + } + } + } + + if catalogCount != len(all) { + t.Fatalf("catalog permission count = %d, AllPermissions count = %d", catalogCount, len(all)) + } + for _, permission := range all { + if _, exists := seen[permission]; !exists { + t.Fatalf("AllPermissions includes %q outside catalog", permission) + } + } +} diff --git a/backend/pkg/authz/permissions.go b/backend/pkg/authz/permissions.go new file mode 100644 index 0000000000..5ccd2412cd --- /dev/null +++ b/backend/pkg/authz/permissions.go @@ -0,0 +1,391 @@ +// Package authz defines the permission taxonomy and authorization primitives +// used across Arcane handlers. Permissions are strings of the form +// ":" and are classified as either org-level (require a +// globally-scoped role) or env-scoped (resolved against the environment ID +// from the request path). +package authz + +// Built-in role IDs. Stable across migrations so other code may reference them +// safely. All six built-in roles are seeded by migration 054_add_rbac. +const ( + BuiltInRoleAdmin = "role_admin" + BuiltInRoleEditor = "role_editor" + BuiltInRoleNoShellEditor = "role_no_shell_editor" + BuiltInRoleDeployer = "role_deployer" + BuiltInRoleMonitor = "role_monitor" + BuiltInRoleViewer = "role_viewer" +) + +// Org-level permissions (require a global-scope role assignment). +const ( + PermUsersList = "users:list" + PermUsersRead = "users:read" + PermUsersCreate = "users:create" + PermUsersUpdate = "users:update" + PermUsersDelete = "users:delete" + + // PermRolesList and the role permissions below cover role management + // (Create / Update / Delete) and role assignment to users. They are reserved + // for global admins and intentionally not exposed as delegated permissions — + // see backend/api/middleware/role.go::RequireGlobalAdmin. Likewise, managing + // OIDC group → role mappings is admin-only because it is effectively another + // path for granting role assignments. + PermRolesList = "roles:list" + PermRolesRead = "roles:read" + + PermApiKeysList = "apikeys:list" + PermApiKeysRead = "apikeys:read" + PermApiKeysCreate = "apikeys:create" + PermApiKeysUpdate = "apikeys:update" + PermApiKeysDelete = "apikeys:delete" + + PermFederatedList = "federated:list" + PermFederatedRead = "federated:read" + PermFederatedCreate = "federated:create" + PermFederatedUpdate = "federated:update" + PermFederatedDelete = "federated:delete" + + PermSettingsRead = "settings:read" + PermSettingsWrite = "settings:write" + + PermEnvironmentsList = "environments:list" + PermEnvironmentsRead = "environments:read" + PermEnvironmentsCreate = "environments:create" + PermEnvironmentsUpdate = "environments:update" + PermEnvironmentsDelete = "environments:delete" + PermEnvironmentsPair = "environments:pair" + PermEnvironmentsSync = "environments:sync" + + PermRegistriesList = "registries:list" + PermRegistriesRead = "registries:read" + PermRegistriesCreate = "registries:create" + PermRegistriesUpdate = "registries:update" + PermRegistriesDelete = "registries:delete" + PermRegistriesTest = "registries:test" + + PermTemplatesList = "templates:list" + PermTemplatesRead = "templates:read" + PermTemplatesCreate = "templates:create" + PermTemplatesUpdate = "templates:update" + PermTemplatesDelete = "templates:delete" + + PermGitReposList = "git-repositories:list" + PermGitReposRead = "git-repositories:read" + PermGitReposCreate = "git-repositories:create" + PermGitReposUpdate = "git-repositories:update" + PermGitReposDelete = "git-repositories:delete" + PermGitReposTest = "git-repositories:test" + PermGitReposSync = "git-repositories:sync" + + PermEventsRead = "events:read" + PermCustomizeManage = "customize:manage" + + // PermDiagnosticsRead gates the admin-only runtime diagnostics surface + // (runtime/memory/GC stats, WebSocket metrics, pprof profiles, and the live + // backend log tail). Global-scoped; seeded only into the Admin role. + PermDiagnosticsRead = "diagnostics:read" +) + +// Env-scoped permissions (resolved against the {id} env ID in the path). +const ( + PermContainersList = "containers:list" + PermContainersRead = "containers:read" + PermContainersLogs = "containers:logs" + PermContainersCreate = "containers:create" + PermContainersStart = "containers:start" + PermContainersStop = "containers:stop" + PermContainersRestart = "containers:restart" + PermContainersRedeploy = "containers:redeploy" + PermContainersDelete = "containers:delete" + PermContainersExec = "containers:exec" + PermContainersAutoUpdate = "containers:autoupdate" + + PermProjectsList = "projects:list" + PermProjectsRead = "projects:read" + PermProjectsLogs = "projects:logs" + PermProjectsCreate = "projects:create" + PermProjectsUpdate = "projects:update" + PermProjectsDeploy = "projects:deploy" + PermProjectsDown = "projects:down" + PermProjectsRestart = "projects:restart" + PermProjectsDelete = "projects:delete" + PermProjectsArchive = "projects:archive" + + PermImagesList = "images:list" + PermImagesRead = "images:read" + PermImagesPull = "images:pull" + PermImagesPush = "images:push" + PermImagesBuild = "images:build" + PermImagesPrune = "images:prune" + PermImagesDelete = "images:delete" + PermImagesUpload = "images:upload" + + PermVolumesList = "volumes:list" + PermVolumesRead = "volumes:read" + PermVolumesCreate = "volumes:create" + PermVolumesDelete = "volumes:delete" + PermVolumesPrune = "volumes:prune" + PermVolumesBrowse = "volumes:browse" + PermVolumesUpload = "volumes:upload" + PermVolumesBackup = "volumes:backup" + + PermNetworksList = "networks:list" + PermNetworksRead = "networks:read" + PermNetworksCreate = "networks:create" + PermNetworksDelete = "networks:delete" + PermNetworksPrune = "networks:prune" + + PermSwarmRead = "swarm:read" + PermSwarmInit = "swarm:init" + PermSwarmJoin = "swarm:join" + PermSwarmLeave = "swarm:leave" + PermSwarmSpec = "swarm:spec" + PermSwarmNodes = "swarm:nodes" + PermSwarmServices = "swarm:services" + PermSwarmServicesLogs = "swarm:services:logs" + PermSwarmStacks = "swarm:stacks" + PermSwarmConfigs = "swarm:configs" + PermSwarmSecrets = "swarm:secrets" + PermSwarmUnlock = "swarm:unlock" + + PermGitOpsList = "gitops:list" + PermGitOpsRead = "gitops:read" + PermGitOpsCreate = "gitops:create" + PermGitOpsUpdate = "gitops:update" + PermGitOpsDelete = "gitops:delete" + PermGitOpsSync = "gitops:sync" + // PermGitOpsLifecycle gates configuring a sync's pre-deploy lifecycle hook + // (script + runner image + env + host bind mounts + network mode), which is + // effectively arbitrary code execution on the host. It is intentionally + // separate from gitops:create / gitops:update so that holding those broader + // permissions (e.g. the built-in Editor role) does not by itself grant the + // ability to author hooks; seeded only into the Admin built-in role. + PermGitOpsLifecycle = "gitops:lifecycle" + + PermWebhooksList = "webhooks:list" + PermWebhooksCreate = "webhooks:create" + PermWebhooksUpdate = "webhooks:update" + PermWebhooksDelete = "webhooks:delete" + + PermJobsManage = "jobs:manage" + PermNotificationsManage = "notifications:manage" + PermDashboardRead = "dashboard:read" + + PermSystemRead = "system:read" + PermSystemPrune = "system:prune" + PermSystemUpgrade = "system:upgrade" + + PermImageUpdatesRead = "image-updates:read" + PermImageUpdatesCheck = "image-updates:check" + + PermVulnsRead = "vulnerabilities:read" + PermVulnsScan = "vulnerabilities:scan" + PermVulnsManage = "vulnerabilities:manage" + + PermBuildWorkspacesManage = "build-workspaces:manage" + + PermActivitiesRead = "activities:read" + PermActivitiesCancel = "activities:cancel" + PermActivitiesDelete = "activities:delete" +) + +// orgLevelPermissions is derived from the authz permission catalog. Env-scoped +// permissions are everything in the catalog that is not in this set. +var orgLevelPermissions = buildOrgLevelPermissionsInternal() + +func buildOrgLevelPermissionsInternal() map[string]struct{} { + out := make(map[string]struct{}) + for _, resource := range permissionCatalog { + if resource.Scope != PermissionScopeGlobal { + continue + } + for _, action := range resource.Actions { + out[action.Permission] = struct{}{} + } + } + return out +} + +// IsOrgLevel reports whether the given permission requires a globally-scoped +// role assignment (and applies only to org-level endpoints). +func IsOrgLevel(perm string) bool { + _, ok := orgLevelPermissions[perm] + return ok +} + +// IsEnvScoped reports whether the given permission is resolved against an +// environment ID. Returns false for permissions not in AllPermissions(). +func IsEnvScoped(perm string) bool { + if _, ok := allPermissionsSet[perm]; !ok { + return false + } + return !IsOrgLevel(perm) +} + +// allPermissionsSet is the canonical exact-set lookup of every defined +// permission, built once from AllPermissions(). Anchors IsKnownPermission and +// IsEnvScoped to exact membership so PermissionSet.IsGlobalAdmin can rely on +// "ps.Global ⊆ allPermissionsSet" as a true invariant. +var allPermissionsSet = func() map[string]struct{} { + all := AllPermissions() + m := make(map[string]struct{}, len(all)) + for _, p := range all { + m[p] = struct{}{} + } + return m +}() + +// totalPermissionsCount caches len(allPermissionsSet) so callers on the auth +// hot path (notably PermissionSet.IsGlobalAdmin) don't re-allocate and walk a +// ~100-element slice on every authenticated request. +var totalPermissionsCount = len(allPermissionsSet) + +// TotalPermissionsCount returns the number of distinct permission constants +// the package defines. Computed once at init; cheap to call repeatedly. +func TotalPermissionsCount() int { return totalPermissionsCount } + +// AllPermissions returns every recognized permission constant. Used to seed +// the Admin built-in role and to validate role definitions. +func AllPermissions() []string { + var count int + for _, resource := range permissionCatalog { + count += len(resource.Actions) + } + out := make([]string, 0, count) + for _, resource := range permissionCatalog { + for _, action := range resource.Actions { + out = append(out, action.Permission) + } + } + return out +} + +// IsKnownPermission reports whether perm matches any defined permission +// constant. Used to reject role definitions referencing unknown permissions. +func IsKnownPermission(perm string) bool { + _, ok := allPermissionsSet[perm] + return ok +} + +// BuiltInEditorPermissions returns the permission set for the Editor built-in +// role: read+write on Docker resources and read on most org-level resources. +// Excludes user/role/key management and settings writes. +func BuiltInEditorPermissions() []string { + return []string{ + // Read on org-level + PermUsersList, PermUsersRead, + PermRolesList, PermRolesRead, + PermApiKeysList, PermApiKeysRead, + PermFederatedList, PermFederatedRead, + PermSettingsRead, + PermEnvironmentsList, PermEnvironmentsRead, PermEnvironmentsSync, + PermRegistriesList, PermRegistriesRead, + PermTemplatesList, PermTemplatesRead, PermTemplatesCreate, PermTemplatesUpdate, PermTemplatesDelete, + PermGitReposList, PermGitReposRead, + PermEventsRead, + // Full env-scoped Docker management + PermContainersList, PermContainersRead, PermContainersLogs, PermContainersCreate, PermContainersStart, PermContainersStop, PermContainersRestart, PermContainersRedeploy, PermContainersDelete, PermContainersExec, PermContainersAutoUpdate, + PermProjectsList, PermProjectsRead, PermProjectsLogs, PermProjectsCreate, PermProjectsUpdate, PermProjectsDeploy, PermProjectsDown, PermProjectsRestart, PermProjectsDelete, PermProjectsArchive, + PermImagesList, PermImagesRead, PermImagesPull, PermImagesPush, PermImagesBuild, PermImagesPrune, PermImagesDelete, PermImagesUpload, + PermVolumesList, PermVolumesRead, PermVolumesCreate, PermVolumesDelete, PermVolumesPrune, PermVolumesBrowse, PermVolumesUpload, PermVolumesBackup, + PermNetworksList, PermNetworksRead, PermNetworksCreate, PermNetworksDelete, PermNetworksPrune, + PermSwarmRead, PermSwarmSpec, PermSwarmNodes, PermSwarmServices, PermSwarmServicesLogs, PermSwarmStacks, PermSwarmConfigs, PermSwarmSecrets, + PermGitOpsList, PermGitOpsRead, PermGitOpsCreate, PermGitOpsUpdate, PermGitOpsDelete, PermGitOpsSync, + PermWebhooksList, PermWebhooksCreate, PermWebhooksUpdate, PermWebhooksDelete, + PermJobsManage, PermNotificationsManage, PermDashboardRead, + PermSystemRead, PermSystemPrune, + PermImageUpdatesRead, PermImageUpdatesCheck, + PermVulnsRead, PermVulnsScan, PermVulnsManage, + PermBuildWorkspacesManage, + PermActivitiesRead, PermActivitiesCancel, PermActivitiesDelete, + } +} + +// BuiltInDeployerPermissions returns the permission set for the Deployer +// built-in role: container/project lifecycle and read-only on everything else. +// Cannot create or delete resources; cannot manage settings/users/roles/keys. +func BuiltInDeployerPermissions() []string { + return []string{ + PermEnvironmentsList, PermEnvironmentsRead, + PermRegistriesList, PermRegistriesRead, + PermTemplatesList, PermTemplatesRead, + PermEventsRead, + PermContainersList, PermContainersRead, PermContainersLogs, PermContainersStart, PermContainersStop, PermContainersRestart, PermContainersRedeploy, + PermProjectsList, PermProjectsRead, PermProjectsLogs, PermProjectsDeploy, PermProjectsDown, PermProjectsRestart, + PermImagesList, PermImagesRead, PermImagesPull, + PermVolumesList, PermVolumesRead, PermVolumesBrowse, + PermNetworksList, PermNetworksRead, + PermSwarmRead, PermSwarmServicesLogs, + PermGitOpsList, PermGitOpsRead, PermGitOpsSync, + PermDashboardRead, + PermSystemRead, + PermImageUpdatesRead, PermImageUpdatesCheck, + PermVulnsRead, + PermActivitiesRead, PermActivitiesCancel, + } +} + +// BuiltInViewerPermissions returns the permission set for the Viewer built-in +// role: read-only access across every resource. +func BuiltInViewerPermissions() []string { + return []string{ + PermUsersList, PermUsersRead, + PermRolesList, PermRolesRead, + PermApiKeysList, PermApiKeysRead, + PermFederatedList, PermFederatedRead, + PermSettingsRead, + PermEnvironmentsList, PermEnvironmentsRead, + PermRegistriesList, PermRegistriesRead, + PermTemplatesList, PermTemplatesRead, + PermGitReposList, PermGitReposRead, + PermEventsRead, + PermContainersList, PermContainersRead, PermContainersLogs, + PermProjectsList, PermProjectsRead, PermProjectsLogs, + PermImagesList, PermImagesRead, + PermVolumesList, PermVolumesRead, PermVolumesBrowse, + PermNetworksList, PermNetworksRead, + PermSwarmRead, PermSwarmServicesLogs, + PermGitOpsList, PermGitOpsRead, + PermWebhooksList, + PermDashboardRead, + PermSystemRead, + PermImageUpdatesRead, + PermVulnsRead, + PermActivitiesRead, + } +} + +// BuiltInMonitorPermissions returns the permission set for the Monitor +// built-in role: observability-only access — read Docker resources, view logs, +// dashboards, and events. No mutations, no exec, no user/role/settings access. +func BuiltInMonitorPermissions() []string { + return []string{ + PermEnvironmentsList, PermEnvironmentsRead, + PermEventsRead, + PermContainersList, PermContainersRead, PermContainersLogs, + PermProjectsList, PermProjectsRead, PermProjectsLogs, + PermImagesList, PermImagesRead, + PermVolumesList, PermVolumesRead, + PermNetworksList, PermNetworksRead, + PermSwarmRead, PermSwarmServicesLogs, + PermDashboardRead, + PermSystemRead, + PermImageUpdatesRead, + PermVulnsRead, + PermActivitiesRead, + } +} + +// BuiltInNoShellEditorPermissions returns the Editor permission set minus +// PermContainersExec. For teams that want full Docker management but no +// interactive shell access into running containers. +func BuiltInNoShellEditorPermissions() []string { + src := BuiltInEditorPermissions() + out := make([]string, 0, len(src)) + for _, p := range src { + if p != PermContainersExec { + out = append(out, p) + } + } + return out +} diff --git a/backend/pkg/dockerutil/cgroup_utils.go b/backend/pkg/dockerutil/cgroup_utils.go index ae38058898..eee826987b 100644 --- a/backend/pkg/dockerutil/cgroup_utils.go +++ b/backend/pkg/dockerutil/cgroup_utils.go @@ -34,7 +34,7 @@ func DetectCgroupLimits() (*CgroupLimits, error) { } if !isInCgroup() { - return nil, fmt.Errorf("not running in a cgroup") + return nil, errors.New("not running in a cgroup") } if isCgroupV2() { @@ -178,7 +178,7 @@ func getCgroupV1Path() (string, error) { return "", fmt.Errorf("error scanning cgroup file: %w", err) } - return "", fmt.Errorf("cgroup path not found") + return "", errors.New("cgroup path not found") } func readCgroupV1Int64(path string) (int64, error) { diff --git a/backend/pkg/dockerutil/compose_labels.go b/backend/pkg/dockerutil/compose_labels.go new file mode 100644 index 0000000000..92b1c07960 --- /dev/null +++ b/backend/pkg/dockerutil/compose_labels.go @@ -0,0 +1,22 @@ +package docker + +import "strings" + +// Docker Compose attaches these labels to every container it manages. They +// identify the owning project and the service within that project. +const ( + ComposeProjectLabelKey = "com.docker.compose.project" + ComposeServiceLabelKey = "com.docker.compose.service" +) + +// ComposeProjectLabel returns the trimmed Docker Compose project name from a +// container's labels, or "" when unset. +func ComposeProjectLabel(labels map[string]string) string { + return strings.TrimSpace(labels[ComposeProjectLabelKey]) +} + +// ComposeServiceLabel returns the trimmed Docker Compose service name from a +// container's labels, or "" when unset. +func ComposeServiceLabel(labels map[string]string) string { + return strings.TrimSpace(labels[ComposeServiceLabelKey]) +} diff --git a/backend/pkg/dockerutil/compose_labels_test.go b/backend/pkg/dockerutil/compose_labels_test.go new file mode 100644 index 0000000000..f96d42539b --- /dev/null +++ b/backend/pkg/dockerutil/compose_labels_test.go @@ -0,0 +1,24 @@ +package docker + +import "testing" + +func TestComposeLabels(t *testing.T) { + labels := map[string]string{ + ComposeProjectLabelKey: " myproj ", + ComposeServiceLabelKey: "web", + } + + if got := ComposeProjectLabel(labels); got != "myproj" { + t.Errorf("ComposeProjectLabel() = %q, want %q", got, "myproj") + } + if got := ComposeServiceLabel(labels); got != "web" { + t.Errorf("ComposeServiceLabel() = %q, want %q", got, "web") + } + + if got := ComposeProjectLabel(nil); got != "" { + t.Errorf("ComposeProjectLabel(nil) = %q, want %q", got, "") + } + if got := ComposeServiceLabel(map[string]string{}); got != "" { + t.Errorf("ComposeServiceLabel(empty) = %q, want %q", got, "") + } +} diff --git a/backend/pkg/dockerutil/container_names.go b/backend/pkg/dockerutil/container_names.go new file mode 100644 index 0000000000..464b59d3d1 --- /dev/null +++ b/backend/pkg/dockerutil/container_names.go @@ -0,0 +1,30 @@ +package docker + +import ( + "strings" + + "github.com/moby/moby/api/types/container" +) + +// ContainerNameFromNames returns Docker's first container name without the +// leading slash Docker stores in container summaries. +func ContainerNameFromNames(names []string) string { + if len(names) == 0 { + return "" + } + + return strings.TrimPrefix(names[0], "/") +} + +// ContainerSummaryName returns a displayable container name from a Docker +// summary, falling back to the short container ID when Docker has no name. +func ContainerSummaryName(cnt container.Summary) string { + if name := ContainerNameFromNames(cnt.Names); name != "" { + return name + } + + if len(cnt.ID) >= 12 { + return cnt.ID[:12] + } + return cnt.ID +} diff --git a/backend/pkg/dockerutil/container_names_test.go b/backend/pkg/dockerutil/container_names_test.go new file mode 100644 index 0000000000..8e1ddd466f --- /dev/null +++ b/backend/pkg/dockerutil/container_names_test.go @@ -0,0 +1,71 @@ +package docker + +import ( + "testing" + + "github.com/moby/moby/api/types/container" +) + +func TestContainerNameFromNames(t *testing.T) { + tests := []struct { + name string + names []string + want string + }{ + { + name: "single name with slash", + names: []string{"/myapp"}, + want: "myapp", + }, + { + name: "single name without slash", + names: []string{"myapp"}, + want: "myapp", + }, + { + name: "multiple names uses first", + names: []string{"/myapp", "/myapp-alias"}, + want: "myapp", + }, + { + name: "no names", + names: []string{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainerNameFromNames(tt.names); got != tt.want { + t.Errorf("ContainerNameFromNames() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContainerSummaryName(t *testing.T) { + tests := []struct { + name string + cnt container.Summary + want string + }{ + { + name: "uses Docker name", + cnt: container.Summary{Names: []string{"/myapp"}, ID: "abc123456789"}, + want: "myapp", + }, + { + name: "falls back to short ID", + cnt: container.Summary{Names: []string{}, ID: "abc123456789"}, + want: "abc123456789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainerSummaryName(tt.cnt); got != tt.want { + t.Errorf("ContainerSummaryName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/pkg/dockerutil/jsonstream.go b/backend/pkg/dockerutil/jsonstream.go index 7612273ec4..482850870e 100644 --- a/backend/pkg/dockerutil/jsonstream.go +++ b/backend/pkg/dockerutil/jsonstream.go @@ -3,9 +3,7 @@ package docker import ( "bufio" "encoding/json" - "errors" "io" - "strings" "github.com/moby/moby/api/types/jsonstream" ) @@ -32,14 +30,6 @@ func ConsumeJSONMessageStream(reader io.Reader, lineHandler func([]byte) error) if msg.Error != nil { return msg.Error } - - // Some daemons include an additional top-level "error" string. - var legacy struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(line, &legacy); err == nil && strings.TrimSpace(legacy.Error) != "" { - return errors.New(strings.TrimSpace(legacy.Error)) - } } if err := scanner.Err(); err != nil { diff --git a/backend/pkg/dockerutil/jsonstream_test.go b/backend/pkg/dockerutil/jsonstream_test.go index 6766fd9d84..783a6a5521 100644 --- a/backend/pkg/dockerutil/jsonstream_test.go +++ b/backend/pkg/dockerutil/jsonstream_test.go @@ -30,11 +30,4 @@ func TestConsumeJSONMessageStream(t *testing.T) { } }) - t.Run("returns legacy top-level error string", func(t *testing.T) { - stream := strings.NewReader(`{"error":"manifest unknown"}` + "\n") - err := ConsumeJSONMessageStream(stream, nil) - if err == nil || !strings.Contains(err.Error(), "manifest unknown") { - t.Fatalf("expected manifest unknown error, got %v", err) - } - }) } diff --git a/backend/pkg/dockerutil/log_stream.go b/backend/pkg/dockerutil/log_stream.go new file mode 100644 index 0000000000..2d74e6b06c --- /dev/null +++ b/backend/pkg/dockerutil/log_stream.go @@ -0,0 +1,170 @@ +package docker + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "sync" + + "github.com/moby/moby/api/pkg/stdcopy" +) + +// StreamContainerLogs streams Docker container logs, handling TTY raw streams +// and non-TTY multiplexed stdout/stderr streams. +func StreamContainerLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string, follow bool, isTTY bool) error { + if isTTY { + return readLogLinesInternal(ctx, logs, logsChan, "") + } + if follow { + return StreamMultiplexedLogs(ctx, logs, logsChan) + } + return ReadAllLogs(ctx, logs, logsChan) +} + +// StreamMultiplexedLogs demultiplexes a Docker stdout/stderr log stream and +// sends non-empty lines to logsChan. +func StreamMultiplexedLogs(ctx context.Context, logs io.Reader, logsChan chan<- string) error { + stdoutReader, stdoutWriter := io.Pipe() + stderrReader, stderrWriter := io.Pipe() + + var closeOnce sync.Once + closeInputs := func() { + closeOnce.Do(func() { + err := ctx.Err() + if err == nil { + err = io.ErrClosedPipe + } + _ = stdoutReader.CloseWithError(err) + _ = stderrReader.CloseWithError(err) + if closer, ok := logs.(io.Closer); ok { + _ = closer.Close() + } + }) + } + + copyDone := make(chan struct{}) + go func() { + defer close(copyDone) + defer func() { _ = stdoutWriter.Close() }() + defer func() { _ = stderrWriter.Close() }() + _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, logs) + if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil { + slog.Error("error demultiplexing logs", "error", err) + } + }() + + go func() { + select { + case <-ctx.Done(): + closeInputs() + case <-copyDone: + } + }() + + done := make(chan error, 2) + + go func() { + done <- readLogLinesInternal(ctx, stdoutReader, logsChan, "") + }() + + go func() { + done <- readLogLinesInternal(ctx, stderrReader, logsChan, "[STDERR] ") + }() + + select { + case <-ctx.Done(): + closeInputs() + return ctx.Err() + case err := <-done: + if err != nil && !errors.Is(err, io.EOF) { + closeInputs() + return err + } + select { + case <-ctx.Done(): + closeInputs() + return ctx.Err() + case <-done: + return nil + } + } +} + +// ReadAllLogs reads a non-follow Docker multiplexed log stream and sends +// non-empty stdout/stderr lines to logsChan. +func ReadAllLogs(ctx context.Context, logs io.ReadCloser, logsChan chan<- string) error { + stdoutBuf := &strings.Builder{} + stderrBuf := &strings.Builder{} + stdCopyDone := make(chan struct{}) + defer close(stdCopyDone) + + go func() { + select { + case <-ctx.Done(): + _ = logs.Close() + case <-stdCopyDone: + } + }() + + _, err := stdcopy.StdCopy(stdoutBuf, stderrBuf, logs) + if err != nil && !errors.Is(err, io.EOF) { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("failed to demultiplex logs: %w", err) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + + if stdoutBuf.Len() > 0 { + if err := readLogLinesInternal(ctx, strings.NewReader(stdoutBuf.String()), logsChan, ""); err != nil { + return err + } + } + + if stderrBuf.Len() > 0 { + if err := readLogLinesInternal(ctx, strings.NewReader(stderrBuf.String()), logsChan, "[STDERR] "); err != nil { + return err + } + } + + return nil +} + +func readLogLinesInternal(ctx context.Context, reader io.Reader, logsChan chan<- string, prefix string) error { + bufferedReader := bufio.NewReader(reader) + + for { + if err := ctx.Err(); err != nil { + return err + } + + line, err := bufferedReader.ReadString('\n') + if len(line) > 0 { + trimmed := strings.TrimRight(line, "\r\n") + if trimmed != "" { + if prefix != "" { + trimmed = prefix + trimmed + } + + select { + case logsChan <- trimmed: + case <-ctx.Done(): + return ctx.Err() + } + } + } + + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + } +} diff --git a/backend/pkg/dockerutil/log_stream_test.go b/backend/pkg/dockerutil/log_stream_test.go new file mode 100644 index 0000000000..bd373c2294 --- /dev/null +++ b/backend/pkg/dockerutil/log_stream_test.go @@ -0,0 +1,198 @@ +package docker + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStreamContainerLogsNonTTYFollowDemultiplexesStdoutAndStderr(t *testing.T) { + var stream bytes.Buffer + writeDockerLogFrameInternal(t, &stream, 1, "stdout line\n") + writeDockerLogFrameInternal(t, &stream, 2, "stderr line\n") + + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, true, false) + require.NoError(t, err) + + require.ElementsMatch(t, []string{"stdout line", "[STDERR] stderr line"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYFollowStreamsRawOutput(t *testing.T) { + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(strings.NewReader("first line\nsecond line")), logsChan, true, true) + require.NoError(t, err) + + require.Equal(t, []string{"first line", "second line"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsNonTTYSnapshotDemultiplexesStdoutAndStderr(t *testing.T) { + var stream bytes.Buffer + writeDockerLogFrameInternal(t, &stream, 1, "stdout snapshot\n") + writeDockerLogFrameInternal(t, &stream, 2, "stderr snapshot\n") + + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(bytes.NewReader(stream.Bytes())), logsChan, false, false) + require.NoError(t, err) + + require.Equal(t, []string{"stdout snapshot", "[STDERR] stderr snapshot"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYSnapshotStreamsRawOutput(t *testing.T) { + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(strings.NewReader("snapshot line\ntrailing line")), logsChan, false, true) + require.NoError(t, err) + + require.Equal(t, []string{"snapshot line", "trailing line"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYHandlesLongLinesAndPartialEOF(t *testing.T) { + longLine := strings.Repeat("a", 70*1024) + logsChan := make(chan string, 4) + + err := StreamContainerLogs(t.Context(), io.NopCloser(strings.NewReader(longLine+"\npartial tail")), logsChan, true, true) + require.NoError(t, err) + + require.Equal(t, []string{longLine, "partial tail"}, drainLogLinesInternal(logsChan)) +} + +func TestStreamContainerLogsTTYPythonLikeFollowDoesNotReturnEmptyLogs(t *testing.T) { + logsChan := make(chan string, 4) + + err := StreamContainerLogs( + t.Context(), + io.NopCloser(strings.NewReader("2026-03-22 10:15:00 - INFO - Starting miner\n2026-03-22 10:15:01 - INFO - Connected")), + logsChan, + true, + true, + ) + require.NoError(t, err) + + lines := drainLogLinesInternal(logsChan) + require.NotEmpty(t, lines) + require.Equal(t, []string{ + "2026-03-22 10:15:00 - INFO - Starting miner", + "2026-03-22 10:15:01 - INFO - Connected", + }, lines) +} + +func TestStreamMultiplexedLogsContextCancelDoesNotDeadlock(t *testing.T) { + var stream bytes.Buffer + writeDockerLogFrameInternal(t, &stream, 1, "line 1\n") + writeDockerLogFrameInternal(t, &stream, 1, "line 2\n") + writeDockerLogFrameInternal(t, &stream, 1, "line 3\n") + + logsChan := make(chan string, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- StreamMultiplexedLogs(ctx, bytes.NewReader(stream.Bytes()), logsChan) + }() + + require.Eventually(t, func() bool { + return len(logsChan) == 1 + }, time.Second, 10*time.Millisecond) + + cancel() + + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("StreamMultiplexedLogs did not exit after cancellation") + } +} + +func TestReadAllLogsContextCancelClosesReader(t *testing.T) { + logsChan := make(chan string, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + reader := &blockingReadCloserInternal{readStarted: make(chan struct{}), closeCalled: make(chan struct{})} + done := make(chan error, 1) + go func() { + done <- ReadAllLogs(ctx, reader, logsChan) + }() + + select { + case <-reader.readStarted: + case <-time.After(time.Second): + t.Fatal("ReadAllLogs did not start reading") + } + + cancel() + + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("ReadAllLogs did not exit after cancellation") + } + + select { + case <-reader.closeCalled: + case <-time.After(time.Second): + t.Fatal("ReadAllLogs did not close the reader on cancellation") + } +} + +func drainLogLinesInternal(logsChan chan string) []string { + close(logsChan) + + lines := make([]string, 0, len(logsChan)) + for line := range logsChan { + lines = append(lines, line) + } + + return lines +} + +func writeDockerLogFrameInternal(t *testing.T, buffer *bytes.Buffer, streamType byte, payload string) { + t.Helper() + + header := make([]byte, 8) + header[0] = streamType + binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) + + _, err := buffer.Write(header) + require.NoError(t, err) + _, err = buffer.WriteString(payload) + require.NoError(t, err) +} + +type blockingReadCloserInternal struct { + readStarted chan struct{} + closeCalled chan struct{} +} + +func (r *blockingReadCloserInternal) Read(_ []byte) (int, error) { + select { + case <-r.readStarted: + default: + close(r.readStarted) + } + + <-r.closeCalled + return 0, io.EOF +} + +func (r *blockingReadCloserInternal) Close() error { + select { + case <-r.closeCalled: + default: + close(r.closeCalled) + } + return nil +} diff --git a/backend/pkg/dockerutil/mount_utils.go b/backend/pkg/dockerutil/mount_utils.go index 27d3f82ed8..bc62debc7b 100644 --- a/backend/pkg/dockerutil/mount_utils.go +++ b/backend/pkg/dockerutil/mount_utils.go @@ -91,6 +91,113 @@ func getCurrentContainerInspectTargetInternal(currentContainerID func() (string, return strings.TrimSpace(value), nil } +// MountForCurrentContainerSubpath inspects the current container, finds the +// existing mount whose destination covers containerPath, and returns a Mount +// suitable for use in another container creation that exposes the same data +// at target. Returns nil + no error if Arcane isn't running inside a +// container or no suitable mount is found — callers can fall back to a +// plain bind on containerPath in that case. +func MountForCurrentContainerSubpath(ctx context.Context, dockerCli *client.Client, containerPath, target string) (*mounttypes.Mount, error) { + if dockerCli == nil { + return nil, nil + } + inspectTarget, err := getCurrentContainerInspectTargetInternal(GetCurrentContainerID, os.Hostname) + if err != nil { + return nil, err + } + inspect, err := libarcane.ContainerInspectWithCompatibility(ctx, dockerCli, inspectTarget, client.ContainerInspectOptions{}) + if err != nil { + return nil, err + } + return MountForSubpath(inspect.Container.Mounts, containerPath, target), nil +} + +// MountForSubpath returns a Mount that exposes a subpath of one of the +// current container's existing mounts at the requested target. It's a +// generalisation of MountForDestination for the case where the caller +// wants a sub-tree below an existing mount destination (e.g. +// "/app/data/projects/X" when "/app/data" is what the container has +// mounted). +// +// The function picks the most-specific mount whose Destination is a +// prefix of containerPath, then constructs the Mount based on the +// backing type: +// +// - TypeBind: Source = mount.Source joined with the relative subpath. +// Works because bind sources are real host paths the daemon +// can address directly. +// - TypeVolume: Source = mount.Name (the volume name), and the relative +// subpath is set on VolumeOptions.Subpath. This lets the +// daemon mount the named volume directly without needing a +// host-side path translation — important for setups where +// the underlying volume storage is opaque (Docker Desktop +// on WSL2, Docker-in-Docker, etc.). +// +// Returns nil if no mount destination is a prefix of containerPath or if +// the matching mount is of an unsupported type. +func MountForSubpath(mounts []containertypes.MountPoint, containerPath string, target string) *mounttypes.Mount { + if strings.TrimSpace(containerPath) == "" { + return nil + } + if strings.TrimSpace(target) == "" { + target = containerPath + } + + var best *containertypes.MountPoint + for i := range mounts { + m := &mounts[i] + if m.Destination == "" { + continue + } + if !pathHasPrefixInternal(containerPath, m.Destination) { + continue + } + if best == nil || len(m.Destination) > len(best.Destination) { + best = m + } + } + if best == nil { + return nil + } + + relative := strings.TrimPrefix(strings.TrimPrefix(containerPath, best.Destination), "/") + readOnly := !best.RW + + switch best.Type { //nolint:exhaustive // only bind and volume mounts are translatable; the default returns nil for the rest + case mounttypes.TypeBind: + if strings.TrimSpace(best.Source) == "" { + return nil + } + source := best.Source + if relative != "" { + source = strings.TrimRight(source, "/") + "/" + relative + } + return &mounttypes.Mount{Type: mounttypes.TypeBind, Source: source, Target: target, ReadOnly: readOnly} + case mounttypes.TypeVolume: + if strings.TrimSpace(best.Name) == "" { + return nil + } + m := &mounttypes.Mount{Type: mounttypes.TypeVolume, Source: best.Name, Target: target, ReadOnly: readOnly} + if relative != "" { + m.VolumeOptions = &mounttypes.VolumeOptions{Subpath: relative} + } + return m + default: + return nil + } +} + +// pathHasPrefixInternal reports whether containerPath is at or under prefix, +// treating both as POSIX-style paths. Avoids false positives like +// "/app/datax" matching "/app/data". +func pathHasPrefixInternal(containerPath, prefix string) bool { + if containerPath == prefix { + return true + } + p := strings.TrimRight(prefix, "/") + "/" + return strings.HasPrefix(containerPath, p) +} + // MountForDestination returns a Mount suitable for container creation that mirrors an // existing container mount at the given destination. // diff --git a/backend/pkg/dockerutil/mount_utils_test.go b/backend/pkg/dockerutil/mount_utils_test.go index a4eb6a472c..3e39b023d5 100644 --- a/backend/pkg/dockerutil/mount_utils_test.go +++ b/backend/pkg/dockerutil/mount_utils_test.go @@ -125,3 +125,85 @@ func TestMountForDestination(t *testing.T) { }) } } + +func TestMountForSubpath(t *testing.T) { + hostBind := containertypes.MountPoint{Type: mounttypes.TypeBind, Source: "/host/projects-root", Destination: "/app/data", RW: true} + namedVolume := containertypes.MountPoint{Type: mounttypes.TypeVolume, Name: "arcane-dev-data", Destination: "/app/data", RW: true} + nestedBind := containertypes.MountPoint{Type: mounttypes.TypeBind, Source: "/host/projects-only", Destination: "/app/data/projects", RW: true} + readOnlyVolume := containertypes.MountPoint{Type: mounttypes.TypeVolume, Name: "ro-vol", Destination: "/app/data", RW: false} + tmpfsMount := containertypes.MountPoint{Type: mounttypes.TypeTmpfs, Destination: "/app/data"} + + t.Run("bind mount with subpath joins host source", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeBind, got.Type) + require.Equal(t, "/host/projects-root/projects/foo", got.Source) + require.Equal(t, "/workspace", got.Target) + require.False(t, got.ReadOnly) + require.Nil(t, got.VolumeOptions) + }) + + t.Run("named volume with subpath uses VolumeOptions.Subpath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{namedVolume}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeVolume, got.Type) + require.Equal(t, "arcane-dev-data", got.Source) + require.Equal(t, "/workspace", got.Target) + require.NotNil(t, got.VolumeOptions) + require.Equal(t, "projects/foo", got.VolumeOptions.Subpath) + }) + + t.Run("picks the most-specific matching mount", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind, nestedBind}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + // nestedBind's destination is more specific, so the relative subpath is "foo" + require.Equal(t, "/host/projects-only/foo", got.Source) + }) + + t.Run("exact-destination match has no subpath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{namedVolume}, "/app/data", "/workspace") + require.NotNil(t, got) + require.Equal(t, mounttypes.TypeVolume, got.Type) + require.Equal(t, "arcane-dev-data", got.Source) + require.Nil(t, got.VolumeOptions, "exact destination match shouldn't add VolumeOptions") + }) + + t.Run("preserves read-only", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{readOnlyVolume}, "/app/data/projects/foo", "/workspace") + require.NotNil(t, got) + require.True(t, got.ReadOnly) + }) + + t.Run("defaults target to containerPath", func(t *testing.T) { + got := MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/data/projects/foo", "") + require.NotNil(t, got) + require.Equal(t, "/app/data/projects/foo", got.Target) + }) + + t.Run("rejects unsupported mount types", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{tmpfsMount}, "/app/data/projects/foo", "/workspace")) + }) + + t.Run("returns nil when no mount destination is a prefix", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "/elsewhere/foo", "/workspace")) + }) + + t.Run("does not match similar-looking destinations", func(t *testing.T) { + // "/app/datax" must not match "/app/data". + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "/app/datax/foo", "/workspace")) + }) + + t.Run("rejects empty containerPath", func(t *testing.T) { + require.Nil(t, MountForSubpath([]containertypes.MountPoint{hostBind}, "", "/workspace")) + }) + + t.Run("rejects bind mount with empty source", func(t *testing.T) { + bad := containertypes.MountPoint{Type: mounttypes.TypeBind, Destination: "/app/data"} + require.Nil(t, MountForSubpath([]containertypes.MountPoint{bad}, "/app/data/projects/foo", "/workspace")) + }) + + t.Run("rejects named volume with empty name", func(t *testing.T) { + bad := containertypes.MountPoint{Type: mounttypes.TypeVolume, Destination: "/app/data"} + require.Nil(t, MountForSubpath([]containertypes.MountPoint{bad}, "/app/data/projects/foo", "/workspace")) + }) +} diff --git a/backend/pkg/fswatch/watcher.go b/backend/pkg/fswatch/watcher.go index 5c8f46ced8..2cbc9ea5bb 100644 --- a/backend/pkg/fswatch/watcher.go +++ b/backend/pkg/fswatch/watcher.go @@ -2,7 +2,7 @@ package fswatch import ( "context" - "fmt" + "errors" "log/slog" "os" "path/filepath" @@ -79,7 +79,7 @@ func (fw *Watcher) Start(ctx context.Context) error { return nil } if fw.stopped { - return fmt.Errorf("watcher already stopped") + return errors.New("watcher already stopped") } if err := fw.watcher.Add(fw.watchedPath); err != nil { @@ -293,13 +293,7 @@ func (fw *Watcher) addExistingDirectoriesRecursiveInternal(path string, logicalP return nil } - watchPath := fw.resolveWatchPathInternal(path) - fw.watchAliases[watchPath] = filepath.Clean(logicalPath) - if err := fw.watcher.Add(watchPath); err != nil { - slog.Warn("Failed to add directory to watcher", - "path", watchPath, - "error", err) - } + fw.addWatchPathInternal(path, logicalPath) if fw.maxDepth > 0 && depth == fw.maxDepth { return nil @@ -325,19 +319,6 @@ func (fw *Watcher) addExistingDirectoriesRecursiveInternal(path string, logicalP return nil } -func (fw *Watcher) resolveWatchPathInternal(path string) string { - if !fw.followSymlinks { - return path - } - - resolvedPath, err := filepath.EvalSymlinks(path) - if err != nil { - return path - } - - return resolvedPath -} - func (fw *Watcher) logicalPathForWatchEventInternal(path string) string { cleanPath := filepath.Clean(path) bestWatchPath := "" @@ -367,6 +348,26 @@ func (fw *Watcher) logicalPathForWatchEventInternal(path string) string { return filepath.Join(bestLogicalPath, rel) } +func (fw *Watcher) addWatchPathInternal(path string, logicalPath string) { + watchPaths := []string{path} + if fw.followSymlinks { + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + if resolvedPath, resolveErr := filepath.EvalSymlinks(path); resolveErr == nil && resolvedPath != path { + watchPaths = append(watchPaths, resolvedPath) + } + } + } + + for _, watchPath := range watchPaths { + fw.watchAliases[filepath.Clean(watchPath)] = filepath.Clean(logicalPath) + if err := fw.watcher.Add(watchPath); err != nil { + slog.Warn("Failed to add directory to watcher", + "path", watchPath, + "error", err) + } + } +} + func (fw *Watcher) dirDepth(path string) int { cleanRoot := fw.watchedPath cleanPath := filepath.Clean(path) diff --git a/backend/pkg/gitutil/git.go b/backend/pkg/gitutil/git.go index f2d3021f52..f44142ea0c 100644 --- a/backend/pkg/gitutil/git.go +++ b/backend/pkg/gitutil/git.go @@ -86,7 +86,7 @@ func (c *Client) getAuth(config AuthConfig) (transport.AuthMethod, error) { return publicKeys, nil } - return nil, fmt.Errorf("ssh key required for ssh authentication") + return nil, errors.New("ssh key required for ssh authentication") case "none": return nil, nil default: @@ -410,7 +410,7 @@ func ValidatePath(repoPath, requestedPath string) error { return fmt.Errorf("invalid path: %w", err) } if strings.HasPrefix(rel, "..") || strings.Contains(rel, string(filepath.Separator)+".."+string(filepath.Separator)) { - return fmt.Errorf("path traversal attempt detected") + return errors.New("path traversal attempt detected") } return nil @@ -435,7 +435,7 @@ func (c *Client) BrowseTree(ctx context.Context, repoPath, targetPath string) ([ } if !info.IsDir() { - return nil, fmt.Errorf("path is not a directory") + return nil, errors.New("path is not a directory") } entries, err := os.ReadDir(fullPath) @@ -545,6 +545,11 @@ type SyncFileInfo struct { Content []byte Size int64 IsBinary bool + // Executable mirrors git's +x bit so callers can preserve it on the + // destination. Required for lifecycle hooks: a script committed as + // 100755 must arrive in the project workspace runnable, otherwise the + // lifecycle runner fails to exec it. + Executable bool } // DirectoryWalkResult holds the result of walking a directory for sync @@ -604,7 +609,7 @@ func (c *Client) WalkDirectory(ctx context.Context, repoPath, composePath string // Validate we found at least one file if len(result.Files) == 0 { - return nil, fmt.Errorf("no files found in sync directory (directory may be empty or all files were skipped)") + return nil, errors.New("no files found in sync directory (directory may be empty or all files were skipped)") } return result, nil @@ -676,11 +681,16 @@ func (c *Client) appendSyncFile(root *os.Root, path string, d fs.DirEntry, resul return fmt.Errorf("total size limit exceeded (max %d bytes)", limits.maxTotalSize) } + executable := false + if info, err := d.Info(); err == nil { + executable = info.Mode()&0o111 != 0 + } result.Files = append(result.Files, SyncFileInfo{ RelativePath: path, Content: content, Size: fileSize, IsBinary: isBinary, + Executable: executable, }) result.TotalFiles++ result.TotalSize += fileSize diff --git a/backend/pkg/gitutil/git_test.go b/backend/pkg/gitutil/git_test.go index a3b330a039..a4ea21abf5 100644 --- a/backend/pkg/gitutil/git_test.go +++ b/backend/pkg/gitutil/git_test.go @@ -6,6 +6,7 @@ import ( "net" "os" "path/filepath" + "slices" "strings" "sync" "testing" @@ -379,6 +380,38 @@ func TestWalkDirectory_BasicWalk(t *testing.T) { } } +func TestWalkDirectory_PreservesExecutableBit(t *testing.T) { + tmpDir := t.TempDir() + writeFileInternal(t, tmpDir, "compose.yaml", minimalCompose()) + writeFileInternal(t, tmpDir, "scripts/hook.sh", []byte("#!/bin/sh\necho hi\n")) + writeFileInternal(t, tmpDir, "README.md", []byte("readme")) + if err := os.Chmod(filepath.Join(tmpDir, "scripts/hook.sh"), 0o755); err != nil { + t.Fatalf("chmod: %v", err) + } + + client := NewClient("") + result, err := client.WalkDirectory(context.Background(), tmpDir, "compose.yaml", 0, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + byPath := map[string]SyncFileInfo{} + for _, f := range result.Files { + byPath[f.RelativePath] = f + } + + hook, ok := byPath[filepath.ToSlash("scripts/hook.sh")] + if !ok { + t.Fatalf("expected scripts/hook.sh in walk result, got %v", byPath) + } + if !hook.Executable { + t.Errorf("expected scripts/hook.sh to be reported Executable, got false") + } + if readme, ok := byPath["README.md"]; ok && readme.Executable { + t.Errorf("expected README.md to not be Executable") + } +} + func TestWalkDirectory_MaxFilesLimit(t *testing.T) { tmpDir := t.TempDir() writeFileInternal(t, tmpDir, "compose.yaml", minimalCompose()) @@ -522,13 +555,7 @@ func TestWalkDirectory_ComposeInSubdirectory(t *testing.T) { t.Fatalf("expected %d files, got %d (%v)", len(expected), len(paths), paths) } for _, want := range expected { - found := false - for _, got := range paths { - if got == want { - found = true - break - } - } + found := slices.Contains(paths, want) if !found { t.Fatalf("expected walked paths to include %q, got %v", want, paths) } @@ -556,13 +583,7 @@ func TestWalkDirectory_NestedSiblingFile(t *testing.T) { t.Fatalf("expected %d files, got %d (%v)", len(expected), len(paths), paths) } for _, want := range expected { - found := false - for _, got := range paths { - if got == want { - found = true - break - } - } + found := slices.Contains(paths, want) if !found { t.Fatalf("expected walked paths to include %q, got %v", want, paths) } @@ -590,13 +611,7 @@ func TestWalkDirectory_SpecialCharsInPath(t *testing.T) { t.Fatalf("expected %d files, got %d (%v)", len(expected), len(paths), paths) } for _, want := range expected { - found := false - for _, got := range paths { - if got == want { - found = true - break - } - } + found := slices.Contains(paths, want) if !found { t.Fatalf("expected walked paths to include %q, got %v", want, paths) } diff --git a/backend/pkg/libarcane/activity/handler.go b/backend/pkg/libarcane/activity/handler.go new file mode 100644 index 0000000000..23da05ed5e --- /dev/null +++ b/backend/pkg/libarcane/activity/handler.go @@ -0,0 +1,161 @@ +package activity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strings" + + "github.com/getarcaneapp/arcane/backend/internal/models" + "github.com/getarcaneapp/arcane/backend/pkg/utils" +) + +// ErrCanceled is the cancellation cause set on an activity's work context when a +// user requests cancellation. Completion paths read context.Cause to record a +// cancelled (rather than failed) terminal status. +var ErrCanceled = errors.New("activity cancelled by user") + +// cancelledMessage is the latest-message recorded when work is cancelled. +const cancelledMessage = "Cancelled by user" + +// CancelledByContext reports whether ctx was cancelled by a user cancellation +// request (as opposed to app shutdown or a deadline). Callers that finalize an +// activity from a possibly-cancelled work context use this to choose between a +// cancelled and a failed terminal status. +func CancelledByContext(ctx context.Context) bool { + return ctx != nil && errors.Is(context.Cause(ctx), ErrCanceled) +} + +type HandlerOptions struct { + EnvironmentID string + Type models.ActivityType + ResourceType string + ResourceID string + ResourceName string + User *models.User + Step string + Message string + SuccessMessage string + Metadata models.JSON +} + +// StartHandlerActivityForUser creates a background activity and returns its ID +// along with a work context the caller MUST use for the underlying operation. +// When the service supports cancellation (implements Tracker), the returned +// context is a cancelable child bound to the activity; cancelling the activity +// cancels this context. The activity registration is released when the activity +// is completed via the service. On failure it returns ("", ctx) unchanged. +func StartHandlerActivityForUser( + ctx context.Context, + activityService Service, + environmentID string, + activityType models.ActivityType, + resourceType string, + resourceID string, + resourceName string, + user *models.User, + step string, + message string, + metadata models.JSON, +) (string, context.Context) { + if activityService == nil { + return "", ctx + } + + activity, err := activityService.StartActivity(ctx, StartRequest{ + EnvironmentID: environmentID, + Type: activityType, + ResourceType: utils.StringPtrFromTrimmed(resourceType), + ResourceID: utils.StringPtrFromTrimmed(resourceID), + ResourceName: utils.StringPtrFromTrimmed(resourceName), + StartedBy: user, + Step: step, + LatestMessage: message, + Metadata: metadata, + }) + if err != nil { + slog.DebugContext(ctx, "failed to start background activity", "type", activityType, "error", err) + return "", ctx + } + + workCtx := ctx + if tracker, ok := activityService.(Tracker); ok { + workCtx = tracker.Track(ctx, activity.ID) + } + return activity.ID, workCtx +} + +func CompleteHandlerActivity(ctx context.Context, activityService Service, activityID string, successMessage string, err error) { + if activityService == nil || strings.TrimSpace(activityID) == "" { + return + } + + status := models.ActivityStatusSuccess + var errMessage *string + finalMessage := successMessage + if err != nil { + // Read the cancellation cause from the (possibly-tracked) work context + // before it is re-wrapped for the DB write below. + if CancelledByContext(ctx) { + status = models.ActivityStatusCancelled + finalMessage = cancelledMessage + } else { + status = models.ActivityStatusFailed + errText := err.Error() + errMessage = &errText + finalMessage = errText + } + } + + activityCtx := utils.ActivityRuntimeContext(ctx, nil) + if _, completeErr := activityService.CompleteActivity(activityCtx, activityID, status, finalMessage, errMessage); completeErr != nil { + slog.DebugContext(activityCtx, "failed to complete background activity", "activityId", activityID, "error", completeErr) + } +} + +// RunHandlerActivity starts an activity, runs action with the activity's work +// context (cancelable when the service supports it), and completes the activity. +// The action MUST use the provided context for its operation so cancellation +// propagates. +func RunHandlerActivity(ctx context.Context, activityService Service, opts HandlerOptions, action func(ctx context.Context) error) (string, error) { + activityID, workCtx := StartHandlerActivityForUser( + ctx, + activityService, + opts.EnvironmentID, + opts.Type, + opts.ResourceType, + opts.ResourceID, + opts.ResourceName, + opts.User, + opts.Step, + opts.Message, + opts.Metadata, + ) + + err := action(workCtx) + CompleteHandlerActivity(workCtx, activityService, activityID, opts.SuccessMessage, err) + return activityID, err +} + +func WriteStartedLine(writer io.Writer, activityID string) { + if writer == nil || strings.TrimSpace(activityID) == "" { + return + } + + payload := map[string]string{ + "type": "activity", + "activityId": activityID, + } + if err := json.NewEncoder(writer).Encode(payload); err != nil { + _, _ = fmt.Fprintf(writer, `{"activityId":%q}`+"\n", activityID) + } +} + +func FlushWriter(writer io.Writer) { + if flusher, ok := writer.(interface{ Flush() }); ok { + flusher.Flush() + } +} diff --git a/backend/pkg/libarcane/activity/requests.go b/backend/pkg/libarcane/activity/requests.go new file mode 100644 index 0000000000..cddfb7dff2 --- /dev/null +++ b/backend/pkg/libarcane/activity/requests.go @@ -0,0 +1,55 @@ +package activity + +import ( + "context" + + "github.com/getarcaneapp/arcane/backend/internal/models" + activitytypes "github.com/getarcaneapp/arcane/types/activity" +) + +type Service interface { + StartActivity(ctx context.Context, req StartRequest) (*activitytypes.Activity, error) + CompleteActivity(ctx context.Context, activityID string, status models.ActivityStatus, finalMessage string, errMessage *string, finalStep ...string) (*activitytypes.Activity, error) +} + +type MessageAppender interface { + AppendMessage(ctx context.Context, activityID string, req AppendMessageRequest) (*activitytypes.Message, error) +} + +// Tracker is an optional interface a Service may implement to make activities +// cancelable. Track derives a cancelable context bound to the activity ID and +// registers it so the activity can later be cancelled via the activity service. +// Implementers release the registration when the activity completes. +type Tracker interface { + Track(ctx context.Context, activityID string) context.Context +} + +type StartRequest struct { + EnvironmentID string + Type models.ActivityType + ResourceType *string + ResourceID *string + ResourceName *string + StartedBy *models.User + Step string + LatestMessage string + Progress *int + Metadata models.JSON +} + +type UpdateRequest struct { + Status models.ActivityStatus + Progress *int + Step *string + LatestMessage *string + Error *string + Metadata models.JSON +} + +type AppendMessageRequest struct { + Level models.ActivityMessageLevel + Message string + Payload models.JSON + Progress *int + Step string +} diff --git a/backend/pkg/libarcane/activity/writer.go b/backend/pkg/libarcane/activity/writer.go new file mode 100644 index 0000000000..993cfde5ba --- /dev/null +++ b/backend/pkg/libarcane/activity/writer.go @@ -0,0 +1,428 @@ +package activity + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "github.com/getarcaneapp/arcane/backend/internal/models" +) + +type Writer struct { + ctx context.Context + activityService MessageAppender + activityID string + writer io.Writer + defaultStep string + queueCh chan writerQueueItem + + mu sync.Mutex + buffer []byte + layers map[string]layerProgress +} + +const writerAppendQueueSize = 128 + +type layerProgress struct { + current int64 + total int64 + status string +} + +type writerAppendMessage struct { + level models.ActivityMessageLevel + message string + payload models.JSON + progress *int + step string +} + +type writerQueueItem struct { + message *writerAppendMessage + flush chan struct{} +} + +func NewWriter(ctx context.Context, activityService MessageAppender, activityID string, writer io.Writer, defaultStep string) io.Writer { + if activityService == nil || strings.TrimSpace(activityID) == "" { + if writer == nil { + return io.Discard + } + return writer + } + if existing, ok := writer.(*Writer); ok { + return existing + } + out := &Writer{ + ctx: ctx, + activityService: activityService, + activityID: strings.TrimSpace(activityID), + writer: writer, + defaultStep: strings.TrimSpace(defaultStep), + queueCh: make(chan writerQueueItem, writerAppendQueueSize), + layers: map[string]layerProgress{}, + } + go out.drainMessagesInternal(ctx) + return out +} + +func (w *Writer) Write(p []byte) (int, error) { + if w.writer != nil { + // Keep activity capture alive when the client-side response stream disconnects. + _, _ = w.writer.Write(p) + } + + w.mu.Lock() + messages := []writerAppendMessage{} + w.buffer = append(w.buffer, p...) + for { + idx := bytes.IndexByte(w.buffer, '\n') + if idx < 0 { + break + } + line := strings.TrimSpace(string(w.buffer[:idx])) + w.buffer = w.buffer[idx+1:] + if message, ok := w.processLineInternal(line); ok { + messages = append(messages, message) + } + } + w.mu.Unlock() + + for _, message := range messages { + w.enqueueMessageInternal(message) + } + + return len(p), nil +} + +func (w *Writer) Flush() { + if flusher, ok := w.writer.(http.Flusher); ok { + flusher.Flush() + } + flushDone := make(chan struct{}) + select { + case w.queueCh <- writerQueueItem{flush: flushDone}: + case <-doneInternal(w.ctx): + return + default: + return + } + select { + case <-flushDone: + case <-doneInternal(w.ctx): + return + } +} + +func (w *Writer) processLineInternal(line string) (writerAppendMessage, bool) { + if line == "" || w.activityService == nil || w.activityID == "" { + return writerAppendMessage{}, false + } + + var payload map[string]any + if err := json.Unmarshal([]byte(line), &payload); err != nil { + return writerAppendMessage{ + level: models.ActivityMessageLevelInfo, + message: line, + step: w.defaultStep, + }, true + } + + message, level, step, progress := w.describePayloadInternal(payload) + if message == "" { + return writerAppendMessage{}, false + } + return writerAppendMessage{ + level: level, + message: message, + payload: models.JSON(payload), + progress: progress, + step: step, + }, true +} + +func (w *Writer) describePayloadInternal(payload map[string]any) (string, models.ActivityMessageLevel, string, *int) { + level := models.ActivityMessageLevelInfo + step := w.defaultStep + + if errorValue, ok := payload["error"]; ok && errorValue != nil { + level = models.ActivityMessageLevelError + return valueToStringInternal(errorValue), level, step, nil + } + + if typ := strings.TrimSpace(valueToStringInternal(payload["type"])); typ != "" { + if typ == "container" { + return containerEventMessageInternal(payload), level, step, nil + } + if phase := strings.TrimSpace(valueToStringInternal(payload["phase"])); phase != "" { + step = phaseStepInternal(typ, phase, step) + progress := phaseProgressInternal(phase, payload["progressDetail"]) + return phaseMessageInternal(typ, phase, payload), level, step, progress + } + } + + if stream := strings.TrimSpace(valueToStringInternal(payload["stream"])); stream != "" { + return stream, level, fallbackStepInternal(step, "Building image"), nil + } + + status := strings.TrimSpace(valueToStringInternal(payload["status"])) + id := strings.TrimSpace(valueToStringInternal(payload["id"])) + progressText := strings.TrimSpace(valueToStringInternal(payload["progress"])) + progress := w.updateLayerProgressInternal(id, status, payload["progressDetail"]) + + if status != "" { + parts := []string{status} + if id != "" { + parts = append(parts, id) + } + if progressText != "" { + parts = append(parts, progressText) + } + return strings.Join(parts, " · "), level, statusStepInternal(status, step), progress + } + + return "", level, step, nil +} + +func (w *Writer) updateLayerProgressInternal(id, status string, rawDetail any) *int { + if id == "" { + return nil + } + + layer := w.layers[id] + layer.status = status + if detail, ok := rawDetail.(map[string]any); ok { + layer.current = numberToInt64Internal(detail["current"]) + layer.total = numberToInt64Internal(detail["total"]) + } + w.layers[id] = layer + + if len(w.layers) == 0 { + return nil + } + + var weighted float64 + for _, item := range w.layers { + statusLower := strings.ToLower(item.status) + switch { + case layerCompleteInternal(statusLower): + weighted++ + case strings.Contains(statusLower, "extracting"): + weighted += 0.95 + case strings.Contains(statusLower, "verifying"): + weighted += 0.92 + case strings.Contains(statusLower, "download complete"): + weighted += 0.85 + case item.total > 0: + weighted += min((float64(item.current)/float64(item.total))*0.85, 0.85) + case strings.Contains(statusLower, "downloading") || strings.Contains(statusLower, "pulling"): + weighted += 0.05 + } + } + + progress := max(min(int((weighted/float64(len(w.layers)))*100), 100), 0) + return &progress +} + +func (w *Writer) enqueueMessageInternal(message writerAppendMessage) { + select { + case w.queueCh <- writerQueueItem{message: &message}: + case <-doneInternal(w.ctx): + return + default: + return + } +} + +func (w *Writer) drainMessagesInternal(ctx context.Context) { + for { + select { + case item := <-w.queueCh: + if item.flush != nil { + close(item.flush) + continue + } + if item.message != nil { + w.appendMessageInternal(ctx, *item.message) + } + case <-doneInternal(ctx): + return + } + } +} + +func doneInternal(ctx context.Context) <-chan struct{} { + if ctx == nil { + return nil + } + return ctx.Done() +} + +func (w *Writer) appendMessageInternal(ctx context.Context, message writerAppendMessage) { + if ctx == nil { + return + } + if _, err := w.activityService.AppendMessage(ctx, w.activityID, AppendMessageRequest{ + Level: message.level, + Message: message.message, + Payload: message.payload, + Progress: message.progress, + Step: message.step, + }); err != nil { + return + } +} + +func valueToStringInternal(value any) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + case nil: + return "" + default: + return fmt.Sprint(typed) + } +} + +func numberToInt64Internal(value any) int64 { + switch typed := value.(type) { + case float64: + return int64(typed) + case float32: + return int64(typed) + case int64: + return typed + case int: + return int64(typed) + case json.Number: + out, _ := typed.Int64() + return out + default: + return 0 + } +} + +func layerCompleteInternal(status string) bool { + return strings.Contains(status, "pull complete") || + strings.Contains(status, "already exists") || + strings.Contains(status, "downloaded newer image") || + strings.Contains(status, "image is up to date") +} + +func statusStepInternal(status, fallback string) string { + lower := strings.ToLower(status) + switch { + case strings.Contains(lower, "downloading") || strings.Contains(lower, "pulling"): + return "Downloading layers" + case strings.Contains(lower, "extracting"): + return "Extracting layers" + case strings.Contains(lower, "verifying") || strings.Contains(lower, "digest"): + return "Verifying image" + case strings.Contains(lower, "building"): + return "Building image" + } + return fallback +} + +func phaseStepInternal(typ, phase, fallback string) string { + switch typ { + case "deploy": + switch phase { + case "begin": + return "Starting deployment" + case "complete": + return "Deployment complete" + default: + return "Deploying services" + } + case "build": + switch phase { + case "begin": + return "Starting build" + case "complete": + return "Build complete" + default: + return "Building image" + } + } + return fallback +} + +func phaseMessageInternal(typ, phase string, payload map[string]any) string { + if status := strings.TrimSpace(valueToStringInternal(payload["status"])); status != "" { + return status + } + if service := strings.TrimSpace(valueToStringInternal(payload["service"])); service != "" { + return fmt.Sprintf("%s %s: %s", typ, phase, service) + } + return fmt.Sprintf("%s %s", typ, phase) +} + +func phaseProgressInternal(phase string, rawDetail any) *int { + switch phase { + case "begin": + return new(5) + case "complete": + return new(100) + default: + return progressDetailPercentInternal(rawDetail) + } +} + +func progressDetailPercentInternal(rawDetail any) *int { + detail, ok := rawDetail.(map[string]any) + if !ok { + return nil + } + + current := numberToInt64Internal(detail["current"]) + total := numberToInt64Internal(detail["total"]) + + var progress int64 + switch { + case total > 0: + progress = (current * 100) / total + default: + progress = current + } + + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + + return new(int(progress)) +} + +func containerEventMessageInternal(payload map[string]any) string { + service := strings.TrimSpace(valueToStringInternal(payload["service"])) + state := strings.TrimSpace(valueToStringInternal(payload["state"])) + status := strings.TrimSpace(valueToStringInternal(payload["status"])) + + if service == "" { + service = "unknown" + } + + if status != "" { + return fmt.Sprintf("Container %s: %s", service, status) + } + if state != "" { + return fmt.Sprintf("Container %s: %s", service, state) + } + return "Container " + service +} + +func fallbackStepInternal(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} diff --git a/backend/pkg/libarcane/activity/writer_test.go b/backend/pkg/libarcane/activity/writer_test.go new file mode 100644 index 0000000000..ae02cbe6f4 --- /dev/null +++ b/backend/pkg/libarcane/activity/writer_test.go @@ -0,0 +1,54 @@ +package activity + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/getarcaneapp/arcane/backend/internal/models" + activitytypes "github.com/getarcaneapp/arcane/types/activity" + "github.com/stretchr/testify/require" +) + +type recordingAppender struct { + messages []AppendMessageRequest +} + +func (a *recordingAppender) AppendMessage(_ context.Context, _ string, req AppendMessageRequest) (*activitytypes.Message, error) { + a.messages = append(a.messages, req) + return &activitytypes.Message{}, nil +} + +type failingWriter struct{} + +func (f failingWriter) Write(_ []byte) (int, error) { + return 0, errors.New("client disconnected") +} + +func TestWriterContinuesActivityCaptureWhenResponseWriterFailsInternal(t *testing.T) { + appender := &recordingAppender{} + writer := NewWriter(context.Background(), appender, "activity-1", failingWriter{}, "Pulling image") + + n, err := writer.Write([]byte("Downloading layer\n")) + require.NoError(t, err) + require.Equal(t, len("Downloading layer\n"), n) + + FlushWriter(writer) + require.Eventually(t, func() bool { + return len(appender.messages) == 1 + }, time.Second, 10*time.Millisecond) + require.Equal(t, "Downloading layer", appender.messages[0].Message) + require.Equal(t, models.ActivityMessageLevelInfo, appender.messages[0].Level) +} + +func TestWriterReturnsWrappedWriteErrorWithoutActivityInternal(t *testing.T) { + writer := NewWriter(context.Background(), nil, "", failingWriter{}, "Pulling image") + + _, err := writer.Write([]byte("Downloading layer\n")) + require.ErrorContains(t, err, "client disconnected") +} + +var _ MessageAppender = (*recordingAppender)(nil) +var _ io.Writer = failingWriter{} diff --git a/backend/pkg/libarcane/crypto/encryption.go b/backend/pkg/libarcane/crypto/encryption.go index 6987ed60ad..b838052bf7 100644 --- a/backend/pkg/libarcane/crypto/encryption.go +++ b/backend/pkg/libarcane/crypto/encryption.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "fmt" "io" "log/slog" @@ -145,7 +146,7 @@ func atomicWriteHexFile(path string, key []byte, mode os.FileMode) error { if err := tmp.Close(); err != nil { return err } - if err := os.Rename(tmpPath, path); err != nil { //nolint:gosec // destination path is internally derived from app-controlled config directory + if err := os.Rename(tmpPath, path); err != nil { return err } if err := os.Chmod(path, mode); err != nil { @@ -163,7 +164,7 @@ func deriveDevKey() []byte { // Encrypt encrypts a plaintext string using AES-GCM func Encrypt(plaintext string) (string, error) { if encryptionKey == nil { - return "", fmt.Errorf("encryption not initialized - call InitEncryption first") + return "", errors.New("encryption not initialized - call InitEncryption first") } if plaintext == "" { @@ -192,7 +193,7 @@ func Encrypt(plaintext string) (string, error) { // Decrypt decrypts a base64 encoded ciphertext string using AES-GCM func Decrypt(ciphertext string) (string, error) { if encryptionKey == nil { - return "", fmt.Errorf("encryption not initialized - call InitEncryption first") + return "", errors.New("encryption not initialized - call InitEncryption first") } if ciphertext == "" { @@ -216,7 +217,7 @@ func Decrypt(ciphertext string) (string, error) { nonceSize := gcm.NonceSize() if len(data) < nonceSize { - return "", fmt.Errorf("ciphertext too short") + return "", errors.New("ciphertext too short") } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] diff --git a/backend/pkg/libarcane/docker_compat.go b/backend/pkg/libarcane/docker_compat.go index 0fe079f82c..e1ebaec493 100644 --- a/backend/pkg/libarcane/docker_compat.go +++ b/backend/pkg/libarcane/docker_compat.go @@ -2,6 +2,7 @@ package libarcane import ( "context" + "errors" "fmt" "slices" "strconv" @@ -82,7 +83,7 @@ func IsDockerAPIVersionAtLeast(current, minimum string) bool { return false } - for i := range len(cur) { + for i := range cur { if cur[i] > minV[i] { return true } @@ -227,7 +228,7 @@ func ContainerCreateWithCompatibility(ctx context.Context, dockerClient client.A // already resolved the daemon API version. func ContainerCreateWithCompatibilityForAPIVersion(ctx context.Context, dockerClient client.APIClient, options client.ContainerCreateOptions, apiVersion string) (client.ContainerCreateResult, error) { if dockerClient == nil { - return client.ContainerCreateResult{}, fmt.Errorf("docker api client is nil") + return client.ContainerCreateResult{}, errors.New("docker api client is nil") } adjustedOptions, extraEndpoints := PrepareContainerCreateOptionsForDockerAPI(options, apiVersion) diff --git a/backend/pkg/libarcane/dockerrun/dockerrun.go b/backend/pkg/libarcane/dockerrun/dockerrun.go index 31619ef390..981d3964eb 100644 --- a/backend/pkg/libarcane/dockerrun/dockerrun.go +++ b/backend/pkg/libarcane/dockerrun/dockerrun.go @@ -1,6 +1,7 @@ package dockerrun import ( + "errors" "fmt" "strings" @@ -20,7 +21,7 @@ func ParseTokens(tokens []string, result *systemtypes.DockerRunCommand) error { } else { if result.Image == "" { if token == "" { - return fmt.Errorf("image name cannot be empty") + return errors.New("image name cannot be empty") } result.Image = token } else { diff --git a/backend/pkg/libarcane/edge/client.go b/backend/pkg/libarcane/edge/client.go index e3f6303a0a..3c24c313ab 100644 --- a/backend/pkg/libarcane/edge/client.go +++ b/backend/pkg/libarcane/edge/client.go @@ -3,6 +3,7 @@ package edge import ( "bytes" "context" + "errors" "fmt" "io" "log/slog" @@ -228,34 +229,55 @@ func (c *TunnelClient) connectAndServeManagedTunnelInternal(ctx context.Context) if c.shouldAttemptWebSocketTunnelInternal() { return c.connectAndServeWebSocket(ctx) } - return fmt.Errorf("no edge tunnel transport is available") + return errors.New("no edge tunnel transport is available") } func (c *TunnelClient) shouldFallbackToWebSocketInternal() bool { - if !c.shouldAttemptWebSocketTunnelInternal() { - return false - } - return c.managerWebSocketURLInternal() != "" + return c.shouldAttemptWebSocketTunnelInternal() } func (c *TunnelClient) shouldAttemptGRPCTunnelInternal() bool { - if c == nil { - return false - } - if UsePollEdgeTransport(c.cfg) { - return strings.TrimSpace(c.managerGRPCAddr) != "" - } - return UseGRPCEdgeTransport(c.cfg) + transports := c.managedTunnelTransportsInternal() + return transports.grpc } func (c *TunnelClient) shouldAttemptWebSocketTunnelInternal() bool { - if c == nil { - return false + transports := c.managedTunnelTransportsInternal() + return transports.websocket +} + +type managedTunnelTransportsInternal struct { + grpc bool + websocket bool +} + +func (c *TunnelClient) managedTunnelTransportsInternal() managedTunnelTransportsInternal { + if c == nil || c.cfg == nil { + return managedTunnelTransportsInternal{} } - if UsePollEdgeTransport(c.cfg) { - return c.managerWebSocketURLInternal() != "" + + managerGRPCAvailable := strings.TrimSpace(c.managerGRPCAddr) != "" + managerWebSocketAvailable := c.managerWebSocketURLInternal() != "" + transport := NormalizeEdgeTransport(c.cfg.EdgeTransport) + + switch transport { + case EdgeTransportPoll: + return managedTunnelTransportsInternal{ + grpc: managerGRPCAvailable, + websocket: managerWebSocketAvailable, + } + case EdgeTransportWebSocket: + return managedTunnelTransportsInternal{ + websocket: managerWebSocketAvailable, + } + case EdgeTransportGRPC: + return managedTunnelTransportsInternal{ + grpc: managerGRPCAvailable, + websocket: strings.TrimSpace(c.cfg.EdgeTransport) == "" && managerWebSocketAvailable, + } + default: + return managedTunnelTransportsInternal{} } - return UseWebSocketEdgeTransport(c.cfg) } func (c *TunnelClient) managerWebSocketURLInternal() string { @@ -334,7 +356,7 @@ func (c *TunnelClient) registerMessageInternal() *TunnelMessage { func (c *TunnelClient) awaitRegistrationInternal(ctx context.Context) (*TunnelMessage, error) { if c == nil || c.conn == nil { - return nil, fmt.Errorf("edge tunnel connection is not initialized") + return nil, errors.New("edge tunnel connection is not initialized") } conn := c.conn @@ -365,7 +387,7 @@ func (c *TunnelClient) awaitRegistrationInternal(ctx context.Context) (*TunnelMe return nil, fmt.Errorf("failed to receive tunnel registration response: %w", result.err) } if result.msg == nil { - return nil, fmt.Errorf("received empty tunnel registration response") + return nil, errors.New("received empty tunnel registration response") } if result.msg.Type != MessageTypeRegisterResponse { return nil, fmt.Errorf("unexpected first tunnel message: %s", result.msg.Type) @@ -919,7 +941,10 @@ func (c *TunnelClient) handleStreamData(ctx context.Context, msg *TunnelMessage) slog.DebugContext(ctx, "Received WebSocket data for unknown stream", "stream_id", msg.ID) return } - stream := streamRaw.(*activeWSStream) + stream, ok := streamRaw.(*activeWSStream) + if !ok { + return + } stream.mu.Lock() if stream.closed { stream.mu.Unlock() @@ -945,7 +970,10 @@ func (c *TunnelClient) handleStreamClose(ctx context.Context, msg *TunnelMessage if !ok { return } - stream := streamRaw.(*activeWSStream) + stream, ok := streamRaw.(*activeWSStream) + if !ok { + return + } c.closeWebSocketStream(msg.ID, stream) slog.DebugContext(ctx, "Closed WebSocket stream", "stream_id", msg.ID) } @@ -1264,25 +1292,25 @@ func (r *streamingResponseRecorder) writeHeaderLocked(statusCode int) error { // StartTunnelClientWithErrors starts the tunnel client and returns a channel for connection errors. func StartTunnelClientWithErrors(ctx context.Context, cfg *Config, handler http.Handler) (<-chan error, error) { if !cfg.EdgeAgent { - return nil, fmt.Errorf("edge tunnel disabled") + return nil, errors.New("edge tunnel disabled") } if UseGRPCEdgeTransport(cfg) { if cfg.GetManagerGRPCAddr() == "" { - return nil, fmt.Errorf("MANAGER_API_URL with a valid host is required for gRPC transport") + return nil, errors.New("MANAGER_API_URL with a valid host is required for gRPC transport") } } if UseWebSocketEdgeTransport(cfg) && strings.TrimSpace(cfg.GetManagerBaseURL()) == "" { - return nil, fmt.Errorf("MANAGER_API_URL is required for websocket transport") + return nil, errors.New("MANAGER_API_URL is required for websocket transport") } if UsePollEdgeTransport(cfg) && strings.TrimSpace(cfg.GetManagerBaseURL()) == "" { - return nil, fmt.Errorf("MANAGER_API_URL is required for poll transport") + return nil, errors.New("MANAGER_API_URL is required for poll transport") } if cfg.AgentToken == "" { - return nil, fmt.Errorf("AGENT_TOKEN is required") + return nil, errors.New("AGENT_TOKEN is required") } if err := EnsureAgentMTLSAssets(ctx, cfg); err != nil { diff --git a/backend/pkg/libarcane/edge/client_grpc_test.go b/backend/pkg/libarcane/edge/client_grpc_test.go index 94c4fa671e..b9b26d2647 100644 --- a/backend/pkg/libarcane/edge/client_grpc_test.go +++ b/backend/pkg/libarcane/edge/client_grpc_test.go @@ -574,98 +574,8 @@ func TestTunnelClient_connectAndServe_WebSocketConfigFallsBackToWebSocket(t *tes } } -func TestTunnelClient_connectAndServe_AutoTransportFallsBackToWebSocketWhenGRPCUnavailable(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - wsConnectedCh := make(chan struct{}, 1) - managerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/tunnel/connect" { - http.NotFound(w, r) - return - } - - upgrader := websocket.Upgrader{} - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer func() { _ = conn.Close() }() - - select { - case wsConnectedCh <- struct{}{}: - default: - } - - time.Sleep(100 * time.Millisecond) - })) - defer managerServer.Close() - - cfg := &Config{ - EdgeTransport: EdgeTransportAuto, - ManagerApiUrl: managerServer.URL, - AgentToken: "valid-token", - } - - client := NewTunnelClient(cfg, http.NotFoundHandler()) - err := client.connectAndServe(ctx) - require.Error(t, err) - - select { - case <-wsConnectedCh: - case <-time.After(2 * time.Second): - t.Fatal("expected auto transport to fall back to websocket") - } -} - -func TestTunnelClient_connectAndServe_AutoTransportDerivesWebSocketFallbackURL(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - wsConnectedCh := make(chan struct{}, 1) - managerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/tunnel/connect" { - http.NotFound(w, r) - return - } - - upgrader := websocket.Upgrader{} - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer func() { _ = conn.Close() }() - - select { - case wsConnectedCh <- struct{}{}: - default: - } - - time.Sleep(100 * time.Millisecond) - })) - defer managerServer.Close() - - cfg := &Config{ - EdgeTransport: EdgeTransportAuto, - ManagerApiUrl: managerServer.URL, - AgentToken: "valid-token", - } - - client := NewTunnelClient(cfg, http.NotFoundHandler()) - client.managerURL = "" - - err := client.connectAndServe(ctx) - require.Error(t, err) - - select { - case <-wsConnectedCh: - case <-time.After(2 * time.Second): - t.Fatal("expected auto transport to derive websocket fallback URL") - } -} - -func TestTunnelClient_connectAndServe_AutoTransportOpensGRPCWhenAvailable(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) +func TestTunnelClient_connectAndServe_OpensGRPCWhenAvailable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) defer cancel() envID := "env-auto-poll-grpc" @@ -687,7 +597,7 @@ func TestTunnelClient_connectAndServe_AutoTransportOpensGRPCWhenAvailable(t *tes defer stopManager() client := NewTunnelClient(&Config{ - EdgeTransport: EdgeTransportAuto, + EdgeTransport: EdgeTransportGRPC, ManagerApiUrl: managerURL, AgentToken: "valid-token", }, http.NotFoundHandler()) @@ -706,8 +616,14 @@ func TestTunnelClient_connectAndServe_AutoTransportOpensGRPCWhenAvailable(t *tes return isGRPC }, 3*time.Second, 20*time.Millisecond) - err := <-errCh - assert.ErrorIs(t, err, context.DeadlineExceeded) + cancel() + + select { + case err := <-errCh: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for gRPC tunnel shutdown") + } } func startTestGRPCTunnelServerOnAPIPathInternal(t *testing.T, ctx context.Context, tunnelServer *TunnelServer) (string, func()) { diff --git a/backend/pkg/libarcane/edge/client_transport_grpc.go b/backend/pkg/libarcane/edge/client_transport_grpc.go index 1d0645372f..a3410f53b6 100644 --- a/backend/pkg/libarcane/edge/client_transport_grpc.go +++ b/backend/pkg/libarcane/edge/client_transport_grpc.go @@ -3,6 +3,7 @@ package edge import ( "context" "crypto/tls" + "errors" "fmt" "log/slog" "net/url" @@ -21,7 +22,7 @@ import ( func (c *TunnelClient) connectAndServeGRPC(ctx context.Context) error { managerAddr := strings.TrimSpace(c.managerGRPCAddr) if managerAddr == "" { - return fmt.Errorf("manager gRPC address is empty") + return errors.New("manager gRPC address is empty") } dialOpts := []grpc.DialOption{ @@ -47,7 +48,7 @@ func (c *TunnelClient) connectAndServeGRPC(ctx context.Context) error { return fmt.Errorf("failed to configure edge gRPC TLS: %w", err) } if tlsConfig == nil { - tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} //nolint:gosec + tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} } dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) } else { diff --git a/backend/pkg/libarcane/edge/client_transport_poll.go b/backend/pkg/libarcane/edge/client_transport_poll.go index bc0f5dfc85..f07885aacd 100644 --- a/backend/pkg/libarcane/edge/client_transport_poll.go +++ b/backend/pkg/libarcane/edge/client_transport_poll.go @@ -25,7 +25,7 @@ type pollManagedTunnelSession struct { func (c *TunnelClient) connectAndServePoll(ctx context.Context) error { managerBaseURL := strings.TrimRight(strings.TrimSpace(c.cfg.GetManagerBaseURL()), "/") if managerBaseURL == "" { - return fmt.Errorf("manager base URL is empty") + return errors.New("manager base URL is empty") } httpClient, err := NewManagerHTTPClient(c.cfg, 0) if err != nil { @@ -202,7 +202,7 @@ func (c *TunnelClient) pollTunnelControlInternal(ctx context.Context, pollURL st } func (c *TunnelClient) startPollManagedSessionInternal(ctx context.Context) *pollManagedTunnelSession { - sessionCtx, cancel := context.WithCancel(ctx) //nolint:gosec // helper intentionally returns the cancel func via the managed session. + sessionCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { @@ -228,7 +228,7 @@ func (c *TunnelClient) stopPollManagedSessionInternal(ctx context.Context, sessi return err case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("timed out waiting for poll-managed websocket session to stop") + return errors.New("timed out waiting for poll-managed websocket session to stop") } return ctx.Err() } diff --git a/backend/pkg/libarcane/edge/client_transport_websocket.go b/backend/pkg/libarcane/edge/client_transport_websocket.go index 76587a6cef..1183abf32d 100644 --- a/backend/pkg/libarcane/edge/client_transport_websocket.go +++ b/backend/pkg/libarcane/edge/client_transport_websocket.go @@ -2,6 +2,7 @@ package edge import ( "context" + "errors" "fmt" "io" "log/slog" @@ -15,7 +16,7 @@ import ( func (c *TunnelClient) connectAndServeWebSocket(ctx context.Context) error { managerWSURL := c.managerWebSocketURLInternal() if managerWSURL == "" { - return fmt.Errorf("manager WebSocket URL is empty") + return errors.New("manager WebSocket URL is empty") } c.managerURL = managerWSURL diff --git a/backend/pkg/libarcane/edge/command_client.go b/backend/pkg/libarcane/edge/command_client.go index 5b8ca9b618..28ac01f5da 100644 --- a/backend/pkg/libarcane/edge/command_client.go +++ b/backend/pkg/libarcane/edge/command_client.go @@ -2,6 +2,7 @@ package edge import ( "context" + "errors" "fmt" "net/http" "time" @@ -34,13 +35,13 @@ func NewCommandClient() *CommandClient { func (c *CommandClient) Execute(ctx context.Context, tunnel *AgentTunnel, req *CommandRequest) (*CommandResult, error) { if ctx == nil { - return nil, fmt.Errorf("context is required") + return nil, errors.New("context is required") } if err := validateConnectedTunnelInternal(tunnel); err != nil { return nil, err } if req == nil { - return nil, fmt.Errorf("command request is required") + return nil, errors.New("command request is required") } commandName := req.Command @@ -100,16 +101,16 @@ func (c *CommandClient) Execute(ctx context.Context, tunnel *AgentTunnel, req *C func (c *CommandClient) OpenStream(ctx context.Context, tunnel *AgentTunnel, req *CommandRequest) error { if ctx == nil { - return fmt.Errorf("context is required") + return errors.New("context is required") } if err := validateConnectedTunnelInternal(tunnel); err != nil { return err } if req == nil { - return fmt.Errorf("command request is required") + return errors.New("command request is required") } if req.ID == "" { - return fmt.Errorf("stream ID is required") + return errors.New("stream ID is required") } commandName := req.Command @@ -143,7 +144,7 @@ var DefaultCommandClient = NewCommandClient() func validateConnectedTunnelInternal(tunnel *AgentTunnel) error { if tunnel == nil || tunnel.Conn == nil || tunnel.Conn.IsClosed() { - return fmt.Errorf("edge tunnel is not connected") + return errors.New("edge tunnel is not connected") } return nil } diff --git a/backend/pkg/libarcane/edge/commands.go b/backend/pkg/libarcane/edge/commands.go index de82f682a9..f7ac6ce688 100644 --- a/backend/pkg/libarcane/edge/commands.go +++ b/backend/pkg/libarcane/edge/commands.go @@ -10,6 +10,7 @@ type commandRoute struct { PathPattern string CommandName string Stream bool + LocalOnly bool } var commandRoutes = []commandRoute{ @@ -48,6 +49,12 @@ var commandRoutes = []commandRoute{ {Method: http.MethodPost, PathPattern: "/api/environments/{id}/image-updates/check-all", CommandName: "image_update.check_all"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/image-updates/summary", CommandName: "image_update.summary"}, + {Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities", CommandName: "activity.list"}, + {Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities/stream", LocalOnly: true}, + {Method: http.MethodGet, PathPattern: "/api/environments/{id}/activities/{activityId}", CommandName: "activity.inspect"}, + {Method: http.MethodPost, PathPattern: "/api/environments/{id}/activities/{activityId}/cancel", CommandName: "activity.cancel"}, + {Method: http.MethodDelete, PathPattern: "/api/environments/{id}/activities/history", CommandName: "activity.history.clear"}, + {Method: http.MethodPost, PathPattern: "/api/environments/{id}/images/{imageId}/vulnerabilities/scan", CommandName: "vulnerability.scan"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/images/{imageId}/vulnerabilities", CommandName: "vulnerability.list"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/images/{imageId}/vulnerabilities/summary", CommandName: "vulnerability.summary"}, @@ -144,9 +151,6 @@ var commandRoutes = []commandRoute{ {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/settings", CommandName: "notification.settings.upsert"}, {Method: http.MethodDelete, PathPattern: "/api/environments/{id}/notifications/settings/{provider}", CommandName: "notification.settings.delete"}, {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/test/{provider}", CommandName: "notification.test"}, - {Method: http.MethodGet, PathPattern: "/api/environments/{id}/notifications/apprise", CommandName: "notification.apprise.get"}, - {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/apprise", CommandName: "notification.apprise.upsert"}, - {Method: http.MethodPost, PathPattern: "/api/environments/{id}/notifications/apprise/test", CommandName: "notification.apprise.test"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/dashboard", CommandName: "dashboard.snapshot"}, {Method: http.MethodGet, PathPattern: "/api/environments/{id}/dashboard/action-items", CommandName: "dashboard.action_items"}, @@ -198,6 +202,10 @@ func ResolveEdgeCommandName(method, requestPath string, stream bool) (string, bo return "", false } + if route.LocalOnly { + return "", false + } + return route.CommandName, true } @@ -210,6 +218,9 @@ func AdvertisedEdgeCommands() []string { seen := make(map[string]struct{}, len(commandRoutes)) commands := make([]string, 0, len(commandRoutes)) for _, route := range commandRoutes { + if route.LocalOnly { + continue + } if _, ok := seen[route.CommandName]; ok { continue } diff --git a/backend/pkg/libarcane/edge/commands_test.go b/backend/pkg/libarcane/edge/commands_test.go index 4e26467ccc..130b36a639 100644 --- a/backend/pkg/libarcane/edge/commands_test.go +++ b/backend/pkg/libarcane/edge/commands_test.go @@ -25,6 +25,11 @@ func TestResolveEdgeCommandName(t *testing.T) { {name: "project logs stream", method: "GET", path: "/api/environments/0/ws/projects/p1/logs", stream: true, command: "project.logs.stream", shouldHit: true}, {name: "project updates", method: "GET", path: "/api/environments/0/projects/p1/updates", command: "project.updates", shouldHit: true}, {name: "project archive", method: "POST", path: "/api/environments/0/projects/p1/archive", command: "project.archive", shouldHit: true}, + {name: "activity list", method: "GET", path: "/api/environments/0/activities?limit=50", command: "activity.list", shouldHit: true}, + {name: "activity inspect", method: "GET", path: "/api/environments/0/activities/activity-1", command: "activity.inspect", shouldHit: true}, + {name: "activity cancel", method: "POST", path: "/api/environments/0/activities/activity-1/cancel", command: "activity.cancel", shouldHit: true}, + {name: "activity history clear", method: "DELETE", path: "/api/environments/0/activities/history", command: "activity.history.clear", shouldHit: true}, + {name: "activity stream remains manager local", method: "GET", path: "/api/environments/0/activities/stream?limit=50", shouldHit: false}, {name: "health", method: "HEAD", path: "/api/environments/0/system/health", command: "system.health", shouldHit: true}, {name: "unknown", method: "PATCH", path: "/api/environments/0/containers", shouldHit: false}, } diff --git a/backend/pkg/libarcane/edge/event_sync.go b/backend/pkg/libarcane/edge/event_sync.go index 10733c295f..173ca0d94f 100644 --- a/backend/pkg/libarcane/edge/event_sync.go +++ b/backend/pkg/libarcane/edge/event_sync.go @@ -2,7 +2,6 @@ package edge import ( "errors" - "fmt" "strings" "sync" @@ -40,13 +39,13 @@ func getActiveAgentTunnelConn() TunnelConnection { // PublishEventToManager sends an event from the active agent tunnel to the manager. func PublishEventToManager(event *TunnelEvent) error { if event == nil { - return fmt.Errorf("event is required") + return errors.New("event is required") } if strings.TrimSpace(event.Type) == "" { - return fmt.Errorf("event type is required") + return errors.New("event type is required") } if strings.TrimSpace(event.Title) == "" { - return fmt.Errorf("event title is required") + return errors.New("event title is required") } conn := getActiveAgentTunnelConn() diff --git a/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go b/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go index 4f74249805..eba05fd770 100644 --- a/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go +++ b/backend/pkg/libarcane/edge/proto/tunnel/v1/tunnel_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: tunnel/v1/tunnel.proto diff --git a/backend/pkg/libarcane/edge/proxy.go b/backend/pkg/libarcane/edge/proxy.go index c2e006d7b5..8a8e38188a 100644 --- a/backend/pkg/libarcane/edge/proxy.go +++ b/backend/pkg/libarcane/edge/proxy.go @@ -46,7 +46,7 @@ func ProxyRequest(ctx context.Context, tunnel *AgentTunnel, method, path, query func registerPendingRequestInternal(tunnel *AgentTunnel, requestID string) (<-chan *TunnelMessage, error) { if requestID == "" { - return nil, fmt.Errorf("request ID is required") + return nil, errors.New("request ID is required") } respCh := make(chan *TunnelMessage, 256) @@ -79,9 +79,8 @@ func collectCommandResponseInternal(ctx context.Context, respCh <-chan *TunnelMe case MessageTypeCommandOutput, MessageTypeStreamData, MessageTypeFileChunk: state.handleStreamData(incoming) case MessageTypeCommandComplete: - if done, status, headers, body, err := state.handleCommandComplete(incoming); done { - return status, headers, body, err - } + status, headers, body, err := state.handleCommandComplete(incoming) + return status, headers, body, err case MessageTypeStreamEnd: if done, status, headers, body := state.handleStreamEnd(); done { return status, headers, body, nil @@ -151,7 +150,7 @@ func (s *grpcResponseState) handleStreamEnd() (bool, int, map[string]string, []b return true, s.status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes() } -func (s *grpcResponseState) handleCommandComplete(incoming *TunnelMessage) (bool, int, map[string]string, []byte, error) { +func (s *grpcResponseState) handleCommandComplete(incoming *TunnelMessage) (int, map[string]string, []byte, error) { if !s.gotResponse { s.gotResponse = true s.status = incoming.Status @@ -161,9 +160,9 @@ func (s *grpcResponseState) handleCommandComplete(incoming *TunnelMessage) (bool s.respBody.Write(incoming.Body) } if incoming.Error != "" && incoming.Status >= http.StatusBadRequest { - return true, incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), errors.New(incoming.Error) + return incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), errors.New(incoming.Error) } - return true, incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), nil + return incoming.Status, stripInternalTunnelHeaders(s.respHeaders), s.respBody.Bytes(), nil } // ProxyHTTPRequest is a helper that proxies an echo context through a tunnel diff --git a/backend/pkg/libarcane/edge/registry.go b/backend/pkg/libarcane/edge/registry.go index 25cf9fefe7..62bfb6c05f 100644 --- a/backend/pkg/libarcane/edge/registry.go +++ b/backend/pkg/libarcane/edge/registry.go @@ -122,9 +122,8 @@ func (r *TunnelRegistry) RegisterSession(tunnel *AgentTunnel, staleAfter time.Du existingStale := staleAfter > 0 && time.Since(existing.GetLastHeartbeat()) > staleAfter sameAgentInstance := existing.AgentInstance != "" && tunnel.AgentInstance != "" && existing.AgentInstance == tunnel.AgentInstance - legacyReplace := existing.AgentInstance == "" || tunnel.AgentInstance == "" - if existing.Conn == nil || existing.Conn.IsClosed() || existingStale || sameAgentInstance || legacyReplace { + if existing.Conn == nil || existing.Conn.IsClosed() || existingStale || sameAgentInstance { if sameAgentInstance { drainPrevious = true } diff --git a/backend/pkg/libarcane/edge/remenv.go b/backend/pkg/libarcane/edge/remenv.go index 5ed8a8c250..6253b5d268 100644 --- a/backend/pkg/libarcane/edge/remenv.go +++ b/backend/pkg/libarcane/edge/remenv.go @@ -10,7 +10,7 @@ import ( ) const ( - HeaderAPIKey = "X-API-Key" // #nosec G101: header name, not a credential + HeaderAPIKey = "X-Api-Key" // #nosec G101: header name, not a credential HeaderAuthorization = "Authorization" HeaderCookie = "Cookie" HeaderAgentToken = "X-Arcane-Agent-Token" // #nosec G101: header name, not a credential diff --git a/backend/pkg/libarcane/edge/server.go b/backend/pkg/libarcane/edge/server.go index 3cc46b9a4d..13910a05a5 100644 --- a/backend/pkg/libarcane/edge/server.go +++ b/backend/pkg/libarcane/edge/server.go @@ -516,7 +516,10 @@ func (s *TunnelServer) handleHeartbeat(ctx context.Context, tunnel *AgentTunnel, func (s *TunnelServer) deliverResponse(ctx context.Context, tunnel *AgentTunnel, msg *TunnelMessage) { if req, ok := tunnel.Pending.Load(msg.ID); ok { - pending := req.(*PendingRequest) + pending, isPending := req.(*PendingRequest) + if !isPending { + return + } select { case pending.ResponseCh <- msg: default: @@ -529,7 +532,10 @@ func (s *TunnelServer) deliverResponse(ctx context.Context, tunnel *AgentTunnel, func (s *TunnelServer) deliverStream(ctx context.Context, tunnel *AgentTunnel, msg *TunnelMessage) { if req, ok := tunnel.Pending.Load(msg.ID); ok { - pending := req.(*PendingRequest) + pending, isPending := req.(*PendingRequest) + if !isPending { + return + } select { case pending.ResponseCh <- msg: case <-ctx.Done(): @@ -652,6 +658,7 @@ func (s *TunnelServer) recoveryStreamInterceptorInternal(ctx context.Context) gr type contextualServerStream struct { grpc.ServerStream + ctx context.Context } diff --git a/backend/pkg/libarcane/edge/tls.go b/backend/pkg/libarcane/edge/tls.go index 99cdd2a3aa..075750c868 100644 --- a/backend/pkg/libarcane/edge/tls.go +++ b/backend/pkg/libarcane/edge/tls.go @@ -105,7 +105,11 @@ func BuildManagerServerTLSConfig(cfg *Config) (*tls.Config, error) { // NewManagerHTTPClient creates an HTTP client for agent-to-manager requests, // applying edge TLS settings when the manager URL uses HTTPS. func NewManagerHTTPClient(cfg *Config, timeout time.Duration) (*http.Client, error) { - transport := http.DefaultTransport.(*http.Transport).Clone() + baseTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, &common.DefaultTransportTypeError{} + } + transport := baseTransport.Clone() tlsConfig, err := buildManagerClientTLSConfigInternal(cfg) if err != nil { return nil, err @@ -144,7 +148,7 @@ func PrepareManagerMTLSAssetsWithContext(ctx context.Context, cfg *Config) error // GeneratedManagerMTLSCAPath returns the configured or Arcane-managed manager CA path without creating assets. func GeneratedManagerMTLSCAPath(cfg *Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("edge config is required") + return "", errors.New("edge config is required") } if configured := strings.TrimSpace(cfg.EdgeMTLSCAFile); configured != "" { return configured, nil @@ -162,7 +166,7 @@ func GenerateManagerClientMTLSAssetsWithContext(ctx context.Context, cfg *Config return nil, nil } if strings.TrimSpace(envID) == "" { - return nil, fmt.Errorf("environment ID is required") + return nil, errors.New("environment ID is required") } assetsDir, err := edgeMTLSAssetsDirInternal(cfg) @@ -243,7 +247,7 @@ func managerMTLSEnrollmentMarkerPathInternal(cfg *Config, envID string) (string, } safeEnvID := generatedAssetNameSanitizer.ReplaceAllString(strings.TrimSpace(envID), "_") if safeEnvID == "" { - return "", fmt.Errorf("environment ID is required") + return "", errors.New("environment ID is required") } return filepath.Join(assetsDir, generatedClientMTLSSubdir, safeEnvID, generatedMTLSEnrolledName), nil } @@ -273,7 +277,7 @@ func GeneratedManagerClientMTLSCertPath(cfg *Config, envID string) (string, erro safeEnvID := generatedAssetNameSanitizer.ReplaceAllString(strings.TrimSpace(envID), "_") if safeEnvID == "" { - return "", fmt.Errorf("environment ID is required") + return "", errors.New("environment ID is required") } return filepath.Join(assetsDir, "clients", safeEnvID, generatedMTLSClientCertName), nil @@ -322,10 +326,10 @@ func EnsureAgentMTLSAssets(ctx context.Context, cfg *Config) error { func enrollAgentMTLSAssetsInternal(ctx context.Context, cfg *Config, assetsDir, certPath, keyPath string) error { managerBaseURL := strings.TrimRight(strings.TrimSpace(cfg.GetManagerBaseURL()), "/") if managerBaseURL == "" { - return fmt.Errorf("MANAGER_API_URL is required to enroll edge mTLS assets") + return errors.New("MANAGER_API_URL is required to enroll edge mTLS assets") } if !managerUsesTLSInternal(cfg) { - return fmt.Errorf("EDGE_MTLS_MODE requires MANAGER_API_URL to use https for certificate enrollment") + return errors.New("EDGE_MTLS_MODE requires MANAGER_API_URL to use https for certificate enrollment") } httpClient, err := NewManagerHTTPClient(cfg, 30*time.Second) @@ -341,7 +345,7 @@ func enrollAgentMTLSAssetsInternal(ctx context.Context, cfg *Config, assetsDir, req.Header.Set(HeaderAPIKey, cfg.AgentToken) req.Header.Set(HeaderAuthorization, "Bearer "+cfg.AgentToken) - resp, err := httpClient.Do(req) //nolint:gosec // intentional request to configured manager endpoint + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("edge mTLS enrollment request failed: %w", err) } @@ -357,7 +361,7 @@ func enrollAgentMTLSAssetsInternal(ctx context.Context, cfg *Config, assetsDir, return fmt.Errorf("failed to decode edge mTLS enrollment response: %w", err) } if len(enrollResp.Files) == 0 { - return fmt.Errorf("edge mTLS enrollment response did not include any files") + return errors.New("edge mTLS enrollment response did not include any files") } if err := os.MkdirAll(assetsDir, common.DirPerm); err != nil { @@ -447,7 +451,7 @@ func ValidateAgentMTLSConfig(cfg *Config) error { } if !managerUsesTLSInternal(cfg) { - return fmt.Errorf("EDGE_MTLS_MODE requires MANAGER_API_URL to use https") + return errors.New("EDGE_MTLS_MODE requires MANAGER_API_URL to use https") } _, err := buildManagerClientTLSConfigInternal(cfg) @@ -480,7 +484,7 @@ func ValidateManagerMTLSConfig(cfg *Config) error { func loadCertPoolInternal(caFile string) (*x509.CertPool, error) { caFile = strings.TrimSpace(caFile) if caFile == "" { - return nil, fmt.Errorf("CA file is required") + return nil, errors.New("CA file is required") } pemBytes, err := os.ReadFile(caFile) @@ -490,7 +494,7 @@ func loadCertPoolInternal(caFile string) (*x509.CertPool, error) { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pemBytes) { - return nil, fmt.Errorf("failed to parse PEM certificates") + return nil, errors.New("failed to parse PEM certificates") } return pool, nil @@ -513,7 +517,7 @@ func loadSystemOrCustomCertPoolInternal(caFile string) (*x509.CertPool, error) { return nil, err } if !pool.AppendCertsFromPEM(pemBytes) { - return nil, fmt.Errorf("failed to parse PEM certificates") + return nil, errors.New("failed to parse PEM certificates") } return pool, nil } @@ -620,11 +624,11 @@ func verifiedPeerCertificateEnvironmentIDMatchesInternal(state *tls.ConnectionSt } expectedPath := expectedEdgeMTLSURIPathInternal(envID) if expectedPath == "" { - return fmt.Errorf("environment ID is required for edge mTLS certificate identity check") + return errors.New("environment ID is required for edge mTLS certificate identity check") } trustDomain = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(trustDomain, "."))) if trustDomain == "" { - return fmt.Errorf("edge mTLS trust domain is required for certificate identity check") + return errors.New("edge mTLS trust domain is required for certificate identity check") } leaf := state.VerifiedChains[0][0] for _, uri := range leaf.URIs { @@ -662,7 +666,7 @@ func expectedEdgeMTLSURIPathInternal(envID string) string { func edgeMTLSAssetsDirInternal(cfg *Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("edge config is required") + return "", errors.New("edge config is required") } if configured := strings.TrimSpace(cfg.EdgeMTLSAssetsDir); configured != "" { @@ -683,7 +687,7 @@ func edgeMTLSAssetsDirInternal(cfg *Config) (string, error) { func edgeAgentMTLSAssetsDirInternal(cfg *Config) (string, error) { if cfg == nil { - return "", fmt.Errorf("edge config is required") + return "", errors.New("edge config is required") } if configured := strings.TrimSpace(cfg.EdgeMTLSAssetsDir); configured != "" { return configured, nil @@ -715,9 +719,6 @@ func ensureManagerCAInternal(ctx context.Context, assetsDir string) (string, str caCertPath := filepath.Join(assetsDir, generatedMTLSCACertFileName) caKeyPath := filepath.Join(assetsDir, generatedMTLSCAKeyFileName) if generatedCAReadyInternal(caCertPath, caKeyPath) { - if err := migratePlainCAKeyInternal(caKeyPath); err != nil { - return "", "", false, err - } return caCertPath, caKeyPath, false, nil } _ = os.Remove(caCertPath) @@ -763,25 +764,6 @@ func generatedCAReadyInternal(caCertPath string, caKeyPath string) bool { return true } -func migratePlainCAKeyInternal(caKeyPath string) error { - if isCAKeyEncryptedOnDiskInternal(caKeyPath) { - return nil - } - keyPEM, err := os.ReadFile(caKeyPath) - if err != nil { - return fmt.Errorf("failed to read legacy plain edge mTLS CA key for migration: %w", err) - } - block, _ := pem.Decode(keyPEM) - if block == nil { - return fmt.Errorf("failed to decode legacy plain edge mTLS CA key for migration") - } - if err := writeCAKeyFileInternal(caKeyPath, block.Bytes); err != nil { - return fmt.Errorf("failed to migrate edge mTLS CA key to encrypted format: %w", err) - } - slog.Info("migrated edge mTLS CA key to encrypted format", "path", caKeyPath) - return nil -} - func ensureClientCertificateInternal(ctx context.Context, assetsDir string, envID string, envName string, appURL string) (string, string, bool, error) { caCertPath, caKeyPath, _, err := ensureManagerCAInternal(ctx, assetsDir) if err != nil { @@ -799,7 +781,7 @@ func ensureClientCertificateInternal(ctx context.Context, assetsDir string, envI caCertBlock, _ := pem.Decode(caCertPEM) if caCertBlock == nil { - return "", "", false, fmt.Errorf("failed to parse CA certificate PEM") + return "", "", false, errors.New("failed to parse CA certificate PEM") } caCert, err := x509.ParseCertificate(caCertBlock.Bytes) if err != nil { @@ -808,7 +790,7 @@ func ensureClientCertificateInternal(ctx context.Context, assetsDir string, envI caKeyBlock, _ := pem.Decode(caKeyPEM) if caKeyBlock == nil { - return "", "", false, fmt.Errorf("failed to parse CA private key PEM") + return "", "", false, errors.New("failed to parse CA private key PEM") } caKey, err := x509.ParseECPrivateKey(caKeyBlock.Bytes) if err != nil { @@ -926,11 +908,11 @@ func validateGeneratedCAInternal(certPath, keyPath string) error { return err } if !cert.IsCA { - return fmt.Errorf("generated CA certificate is not a CA") + return errors.New("generated CA certificate is not a CA") } publicKey, ok := cert.PublicKey.(*ecdsa.PublicKey) if !ok || publicKey.Curve != elliptic.P384() { - return fmt.Errorf("generated CA certificate is not ECDSA P-384") + return errors.New("generated CA certificate is not ECDSA P-384") } keyPEM, err := readCAKeyPEMInternal(keyPath) if err != nil { @@ -945,7 +927,7 @@ func validateGeneratedCAInternal(certPath, keyPath string) error { return fmt.Errorf("failed to parse CA private key %s: %w", keyPath, err) } if privateKey.Curve != elliptic.P384() { - return fmt.Errorf("generated CA private key is not ECDSA P-384") + return errors.New("generated CA private key is not ECDSA P-384") } if err := validateCertificateKeyPairInternal(cert, privateKey, "generated CA"); err != nil { return err @@ -960,7 +942,7 @@ func validateGeneratedClientCertificateInternal(certPath, keyPath string, expect } now := time.Now() if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) { - return fmt.Errorf("generated client certificate is not currently valid") + return errors.New("generated client certificate is not currently valid") } if strings.TrimSpace(expectedCommonName) != "" && cert.Subject.CommonName != expectedCommonName { return fmt.Errorf("generated client certificate common name %q does not match expected %q", cert.Subject.CommonName, expectedCommonName) @@ -970,14 +952,14 @@ func validateGeneratedClientCertificateInternal(certPath, keyPath string, expect } publicKey, ok := cert.PublicKey.(*ecdsa.PublicKey) if !ok || publicKey.Curve != elliptic.P384() { - return fmt.Errorf("generated client certificate is not ECDSA P-384") + return errors.New("generated client certificate is not ECDSA P-384") } privateKey, err := readECPrivateKeyInternal(keyPath) if err != nil { return err } if privateKey.Curve != elliptic.P384() { - return fmt.Errorf("generated client private key is not ECDSA P-384") + return errors.New("generated client private key is not ECDSA P-384") } if err := validateCertificateKeyPairInternal(cert, privateKey, "generated client"); err != nil { return err @@ -1021,13 +1003,13 @@ func agentMTLSAssetsNeedEnrollmentInternal(certPath string, keyPath string, now now = time.Now() } if now.Before(cert.NotBefore) { - return true, fmt.Sprintf("certificate is not valid before %s", cert.NotBefore.UTC().Format(time.RFC3339)) + return true, "certificate is not valid before " + cert.NotBefore.UTC().Format(time.RFC3339) } if !now.Before(cert.NotAfter) { - return true, fmt.Sprintf("certificate expired at %s", cert.NotAfter.UTC().Format(time.RFC3339)) + return true, "certificate expired at " + cert.NotAfter.UTC().Format(time.RFC3339) } if now.Add(agentMTLSRenewBefore).After(cert.NotAfter) { - return true, fmt.Sprintf("certificate expires soon at %s", cert.NotAfter.UTC().Format(time.RFC3339)) + return true, "certificate expires soon at " + cert.NotAfter.UTC().Format(time.RFC3339) } return false, "" } @@ -1104,7 +1086,10 @@ func lockEdgeMTLSPathInternal(ctx context.Context, dir string, lockName string) lockPath := filepath.Join(absDir, lockName) muValue, _ := managerCALocks.LoadOrStore(lockPath, &sync.Mutex{}) - mu := muValue.(*sync.Mutex) + mu, ok := muValue.(*sync.Mutex) + if !ok { + return nil, &common.ManagerCALockTypeError{} + } deadline := time.Now().Add(managerCALockTimeout) for { @@ -1180,14 +1165,14 @@ func readEdgeMTLSLockInfoInternal(lockPath string) (*edgeMTLSLockInfo, error) { } fields := strings.Fields(string(content)) if len(fields) == 0 { - return nil, fmt.Errorf("edge mTLS lock does not contain a PID") + return nil, errors.New("edge mTLS lock does not contain a PID") } pid, err := strconv.Atoi(fields[0]) if err != nil { return nil, fmt.Errorf("parse edge mTLS lock PID: %w", err) } if pid <= 0 { - return nil, fmt.Errorf("edge mTLS lock PID must be positive") + return nil, errors.New("edge mTLS lock PID must be positive") } info := &edgeMTLSLockInfo{pid: pid} if len(fields) > 1 { @@ -1231,13 +1216,11 @@ const caKeyEncryptedPrefix = "ARCANE-ENC-V1:" var caKeyEncryptInternal = libcrypto.Encrypt // writeCAKeyFileInternal writes the edge CA private key to disk using envelope -// encryption via libcrypto. The on-disk format is self-describing so -// readCAKeyPEMInternal can transparently handle encrypted files and legacy -// plaintext files. +// encryption via libcrypto. func writeCAKeyFileInternal(path string, derBytes []byte) error { pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: derBytes}) if pemBytes == nil { - return fmt.Errorf("failed to encode CA private key to PEM") + return errors.New("failed to encode CA private key to PEM") } ciphertext, err := caKeyEncryptInternal(string(pemBytes)) @@ -1245,43 +1228,29 @@ func writeCAKeyFileInternal(path string, derBytes []byte) error { return fmt.Errorf("failed to encrypt edge mTLS CA private key: %w", err) } if ciphertext == "" { - return fmt.Errorf("failed to encrypt edge mTLS CA private key: encrypted payload is empty") + return errors.New("failed to encrypt edge mTLS CA private key: encrypted payload is empty") } return writeFileAtomicInternal(path, []byte(caKeyEncryptedPrefix+ciphertext), 0o600) } // readCAKeyPEMInternal returns the plain PEM bytes of the edge CA private key, -// accepting either the legacy plain PEM file or a libcrypto-envelope-encrypted -// file written by writeCAKeyFileInternal. +// reading a libcrypto-envelope-encrypted file written by writeCAKeyFileInternal. func readCAKeyPEMInternal(path string) ([]byte, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read CA private key %s: %w", path, err) } trimmed := strings.TrimSpace(string(raw)) - if strings.HasPrefix(trimmed, caKeyEncryptedPrefix) { - ciphertext := strings.TrimPrefix(trimmed, caKeyEncryptedPrefix) - plaintext, err := libcrypto.Decrypt(ciphertext) - if err != nil { - return nil, fmt.Errorf("failed to decrypt CA private key %s: %w", path, err) - } - return []byte(plaintext), nil + if !strings.HasPrefix(trimmed, caKeyEncryptedPrefix) { + return nil, fmt.Errorf("CA private key %s is not in the expected encrypted envelope format", path) } - return raw, nil -} - -// isCAKeyEncryptedOnDiskInternal reports whether the given CA key file is -// stored in the libcrypto envelope format. -func isCAKeyEncryptedOnDiskInternal(path string) bool { - f, err := os.Open(path) + ciphertext := strings.TrimPrefix(trimmed, caKeyEncryptedPrefix) + plaintext, err := libcrypto.Decrypt(ciphertext) if err != nil { - return false + return nil, fmt.Errorf("failed to decrypt CA private key %s: %w", path, err) } - defer func() { _ = f.Close() }() - head := make([]byte, len(caKeyEncryptedPrefix)) - n, _ := io.ReadFull(f, head) - return n == len(caKeyEncryptedPrefix) && string(head) == caKeyEncryptedPrefix + return []byte(plaintext), nil } // writeFileAtomicInternal writes data to path via a temp file + rename. diff --git a/backend/pkg/libarcane/edge/tls_test.go b/backend/pkg/libarcane/edge/tls_test.go index babe5ee413..bcd28be0d0 100644 --- a/backend/pkg/libarcane/edge/tls_test.go +++ b/backend/pkg/libarcane/edge/tls_test.go @@ -215,7 +215,7 @@ func TestEnsureAgentMTLSAssets_UsesDownloadedCAPathWhenPresent(t *testing.T) { func TestRemoveStaleEdgeMTLSLockInternal_PreservesLivePID(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".ca.lock") - require.NoError(t, os.WriteFile(lockPath, []byte(fmt.Sprintf("%d %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339Nano))), 0o600)) + require.NoError(t, os.WriteFile(lockPath, fmt.Appendf(nil, "%d %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339Nano)), 0o600)) require.False(t, removeStaleEdgeMTLSLockInternal(lockPath)) require.FileExists(t, lockPath) @@ -224,7 +224,7 @@ func TestRemoveStaleEdgeMTLSLockInternal_PreservesLivePID(t *testing.T) { func TestRemoveStaleEdgeMTLSLockInternal_RemovesOldLockDespiteLivePID(t *testing.T) { lockPath := filepath.Join(t.TempDir(), ".ca.lock") oldTimestamp := time.Now().Add(-(2*managerCALockTimeout + time.Second)).UTC().Format(time.RFC3339Nano) - require.NoError(t, os.WriteFile(lockPath, []byte(fmt.Sprintf("%d %s\n", os.Getpid(), oldTimestamp)), 0o600)) + require.NoError(t, os.WriteFile(lockPath, fmt.Appendf(nil, "%d %s\n", os.Getpid(), oldTimestamp), 0o600)) require.True(t, removeStaleEdgeMTLSLockInternal(lockPath)) require.NoFileExists(t, lockPath) @@ -488,9 +488,6 @@ func TestCAKey_EncryptedOnDiskWhenCryptoInitialized(t *testing.T) { require.FileExists(t, caCertPath) require.FileExists(t, caKeyPath) - require.True(t, isCAKeyEncryptedOnDiskInternal(caKeyPath), - "CA key file must be stored in the encrypted envelope format when libcrypto is initialized") - raw, err := os.ReadFile(caKeyPath) require.NoError(t, err) require.False(t, strings.Contains(string(raw), "BEGIN EC PRIVATE KEY"), @@ -509,49 +506,6 @@ func TestCAKey_EncryptedOnDiskWhenCryptoInitialized(t *testing.T) { require.FileExists(t, clientKeyPath) } -func TestCAKey_MigratesLegacyPlainFile(t *testing.T) { - initEdgeTestCrypto(t) - - assetsDir := t.TempDir() - _, caKeyPath, _, err := ensureManagerCAInternal(context.Background(), assetsDir) - require.NoError(t, err) - - pemBytes, err := readCAKeyPEMInternal(caKeyPath) - require.NoError(t, err) - require.NoError(t, os.WriteFile(caKeyPath, pemBytes, 0o600)) - require.False(t, isCAKeyEncryptedOnDiskInternal(caKeyPath)) - - _, caKeyPath2, _, err := ensureManagerCAInternal(context.Background(), assetsDir) - require.NoError(t, err) - require.Equal(t, caKeyPath, caKeyPath2) - require.True(t, isCAKeyEncryptedOnDiskInternal(caKeyPath), - "legacy plain CA key file must be migrated to the encrypted envelope format on next load") -} - -func TestCAKey_LegacyPlainMigrationFailureReturnsError(t *testing.T) { - initEdgeTestCrypto(t) - - assetsDir := t.TempDir() - _, caKeyPath, _, err := ensureManagerCAInternal(context.Background(), assetsDir) - require.NoError(t, err) - - pemBytes, err := readCAKeyPEMInternal(caKeyPath) - require.NoError(t, err) - require.NoError(t, os.WriteFile(caKeyPath, pemBytes, 0o600)) - - originalEncrypt := caKeyEncryptInternal - t.Cleanup(func() { - caKeyEncryptInternal = originalEncrypt - }) - caKeyEncryptInternal = func(string) (string, error) { - return "", errors.New("encrypt failed") - } - - _, _, _, err = ensureManagerCAInternal(context.Background(), assetsDir) - require.Error(t, err) - require.Contains(t, err.Error(), "failed to migrate edge mTLS CA key to encrypted format") -} - func TestCAKey_EncryptionFailureReturnsError(t *testing.T) { originalEncrypt := caKeyEncryptInternal t.Cleanup(func() { diff --git a/backend/pkg/libarcane/edge/transport.go b/backend/pkg/libarcane/edge/transport.go index e5fac62c65..11a96a84ac 100644 --- a/backend/pkg/libarcane/edge/transport.go +++ b/backend/pkg/libarcane/edge/transport.go @@ -84,9 +84,6 @@ const ( // EdgeTransportPoll uses an HTTP polling control plane with the existing // websocket tunnel as an on-demand data plane. EdgeTransportPoll = "poll" - // EdgeTransportAuto preserves the legacy managed tunnel behavior: try gRPC - // first and fall back to websocket when available. - EdgeTransportAuto = "auto" // EdgeMTLSModeDisabled disables edge tunnel mTLS. EdgeMTLSModeDisabled = "disabled" @@ -97,8 +94,7 @@ const ( EdgeMTLSModeRequired = "required" ) -// NormalizeEdgeTransport normalizes transport config and defaults to the legacy -// managed tunnel auto mode for backwards compatibility. +// NormalizeEdgeTransport normalizes transport config and defaults to gRPC. func NormalizeEdgeTransport(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case EdgeTransportWebSocket: @@ -107,10 +103,8 @@ func NormalizeEdgeTransport(value string) string { return EdgeTransportGRPC case EdgeTransportPoll: return EdgeTransportPoll - case EdgeTransportAuto: - return EdgeTransportAuto default: - return EdgeTransportAuto + return EdgeTransportGRPC } } @@ -131,8 +125,7 @@ func UseGRPCEdgeTransport(cfg *Config) bool { if cfg == nil { return false } - transport := NormalizeEdgeTransport(cfg.EdgeTransport) - return transport == EdgeTransportGRPC || transport == EdgeTransportAuto + return NormalizeEdgeTransport(cfg.EdgeTransport) == EdgeTransportGRPC } // UseWebSocketEdgeTransport reports whether websocket managed tunnel mode is allowed. @@ -140,8 +133,7 @@ func UseWebSocketEdgeTransport(cfg *Config) bool { if cfg == nil { return false } - transport := NormalizeEdgeTransport(cfg.EdgeTransport) - return transport == EdgeTransportWebSocket || transport == EdgeTransportAuto + return NormalizeEdgeTransport(cfg.EdgeTransport) == EdgeTransportWebSocket } // UsePollEdgeTransport reports whether the Portainer-style polling control plane diff --git a/backend/pkg/libarcane/edge/transport_test.go b/backend/pkg/libarcane/edge/transport_test.go index 3e4b2e6635..7f21a2afd4 100644 --- a/backend/pkg/libarcane/edge/transport_test.go +++ b/backend/pkg/libarcane/edge/transport_test.go @@ -9,13 +9,12 @@ import ( ) func TestNormalizeEdgeTransport(t *testing.T) { - assert.Equal(t, EdgeTransportAuto, NormalizeEdgeTransport("")) + assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("")) assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("grpc")) assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("GRPC")) assert.Equal(t, EdgeTransportPoll, NormalizeEdgeTransport("poll")) assert.Equal(t, EdgeTransportWebSocket, NormalizeEdgeTransport("websocket")) - assert.Equal(t, EdgeTransportAuto, NormalizeEdgeTransport("invalid")) - assert.Equal(t, EdgeTransportAuto, NormalizeEdgeTransport("auto")) + assert.Equal(t, EdgeTransportGRPC, NormalizeEdgeTransport("invalid")) } func TestNormalizeEdgeMTLSMode(t *testing.T) { @@ -29,14 +28,12 @@ func TestUseGRPCEdgeTransport(t *testing.T) { assert.False(t, UseGRPCEdgeTransport(nil)) assert.True(t, UseGRPCEdgeTransport(&Config{EdgeTransport: "grpc"})) assert.True(t, UseGRPCEdgeTransport(&Config{EdgeTransport: ""})) - assert.True(t, UseGRPCEdgeTransport(&Config{EdgeTransport: "auto"})) assert.False(t, UseGRPCEdgeTransport(&Config{EdgeTransport: "websocket"})) } func TestUseWebSocketEdgeTransport(t *testing.T) { assert.False(t, UseWebSocketEdgeTransport(nil)) - assert.True(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: ""})) - assert.True(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "auto"})) + assert.False(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: ""})) assert.False(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "grpc"})) assert.False(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "poll"})) assert.True(t, UseWebSocketEdgeTransport(&Config{EdgeTransport: "websocket"})) @@ -45,7 +42,6 @@ func TestUseWebSocketEdgeTransport(t *testing.T) { func TestUsePollEdgeTransport(t *testing.T) { assert.False(t, UsePollEdgeTransport(nil)) assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: ""})) - assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: "auto"})) assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: "grpc"})) assert.False(t, UsePollEdgeTransport(&Config{EdgeTransport: "websocket"})) assert.True(t, UsePollEdgeTransport(&Config{EdgeTransport: "poll"})) diff --git a/backend/pkg/libarcane/edge/tunnel.go b/backend/pkg/libarcane/edge/tunnel.go index f96aa9adda..b4523f7f09 100644 --- a/backend/pkg/libarcane/edge/tunnel.go +++ b/backend/pkg/libarcane/edge/tunnel.go @@ -219,13 +219,13 @@ func (t *TunnelConn) SendRequest(ctx context.Context, msg *TunnelMessage, pendin } type grpcManagerStream interface { - Send(*tunnelpb.ManagerMessage) error + Send(msg *tunnelpb.ManagerMessage) error Recv() (*tunnelpb.AgentMessage, error) Context() context.Context } type grpcAgentStream interface { - Send(*tunnelpb.AgentMessage) error + Send(msg *tunnelpb.AgentMessage) error Recv() (*tunnelpb.ManagerMessage, error) Context() context.Context CloseSend() error @@ -418,7 +418,9 @@ func (t *GRPCAgentTunnelConn) markClosed() { t.closedMu.Unlock() } -func sendRequestWithPending(ctx context.Context, conn interface{ Send(*TunnelMessage) error }, msg *TunnelMessage, pending *sync.Map) (*TunnelMessage, error) { +func sendRequestWithPending(ctx context.Context, conn interface { + Send(msg *TunnelMessage) error +}, msg *TunnelMessage, pending *sync.Map) (*TunnelMessage, error) { ctx, cancel := ensureSendRequestContextInternal(ctx) defer cancel() @@ -476,14 +478,14 @@ func (s *cancelableGRPCManagerStream) Context() context.Context { func ensureSendRequestContextInternal(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { - return context.WithTimeout(context.Background(), defaultSendRequestTimeout) //nolint:gosec // helper intentionally returns the cancel func to callers. + return context.WithTimeout(context.Background(), defaultSendRequestTimeout) } if _, hasDeadline := ctx.Deadline(); hasDeadline { return ctx, func() {} } - return context.WithTimeout(ctx, defaultSendRequestTimeout) //nolint:gosec // helper intentionally returns the cancel func to callers. + return context.WithTimeout(ctx, defaultSendRequestTimeout) } func isExpectedGRPCReceiveErrorInternal(err error) bool { diff --git a/backend/pkg/libarcane/edge/tunnel_proto_adapter.go b/backend/pkg/libarcane/edge/tunnel_proto_adapter.go index 55a668b68c..16cefebf5b 100644 --- a/backend/pkg/libarcane/edge/tunnel_proto_adapter.go +++ b/backend/pkg/libarcane/edge/tunnel_proto_adapter.go @@ -1,6 +1,7 @@ package edge import ( + "errors" "fmt" "maps" "math" @@ -10,7 +11,7 @@ import ( func tunnelMessageToManagerProto(msg *TunnelMessage) (*tunnelpb.ManagerMessage, error) { if msg == nil { - return nil, fmt.Errorf("message is nil") + return nil, errors.New("message is nil") } switch msg.Type { @@ -119,7 +120,7 @@ func tunnelMessageToManagerProto(msg *TunnelMessage) (*tunnelpb.ManagerMessage, func managerProtoToTunnelMessage(msg *tunnelpb.ManagerMessage) (*TunnelMessage, error) { if msg == nil { - return nil, fmt.Errorf("manager message is nil") + return nil, errors.New("manager message is nil") } switch payload := msg.GetPayload().(type) { @@ -214,7 +215,7 @@ func managerProtoToTunnelMessage(msg *tunnelpb.ManagerMessage) (*TunnelMessage, func tunnelMessageToAgentProto(msg *TunnelMessage) (*tunnelpb.AgentMessage, error) { if msg == nil { - return nil, fmt.Errorf("message is nil") + return nil, errors.New("message is nil") } switch msg.Type { @@ -328,7 +329,7 @@ func tunnelMessageToAgentProto(msg *TunnelMessage) (*tunnelpb.AgentMessage, erro func agentProtoToTunnelMessage(msg *tunnelpb.AgentMessage) (*TunnelMessage, error) { if msg == nil { - return nil, fmt.Errorf("agent message is nil") + return nil, errors.New("agent message is nil") } switch payload := msg.GetPayload().(type) { diff --git a/backend/pkg/libarcane/imageupdate/digest.go b/backend/pkg/libarcane/imageupdate/digest.go index e37974fa70..46d3cfb6da 100644 --- a/backend/pkg/libarcane/imageupdate/digest.go +++ b/backend/pkg/libarcane/imageupdate/digest.go @@ -2,12 +2,13 @@ package imageupdate import ( "context" + "errors" "fmt" "log/slog" "strings" "github.com/moby/moby/client" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" ) type remoteDigestResolver interface { @@ -83,7 +84,7 @@ func (c *DigestChecker) CheckImageNeedsUpdate(ctx context.Context, imageRef stri result.LocalDigest = localDigest if c.digestResolver == nil { - result.Error = fmt.Errorf("remote digest resolver unavailable") + result.Error = errors.New("remote digest resolver unavailable") return result } @@ -130,7 +131,7 @@ func (c *DigestChecker) getLocalDigestInternal(ctx context.Context, imageRef str return inspect.ID, nil } - return "", fmt.Errorf("no digest available for image") + return "", errors.New("no digest available for image") } // CompareWithPulled compares the current container's image with a freshly pulled image diff --git a/backend/pkg/libarcane/imageupdate/digest_test.go b/backend/pkg/libarcane/imageupdate/digest_test.go index 0339f7f293..f2d3a0fbf0 100644 --- a/backend/pkg/libarcane/imageupdate/digest_test.go +++ b/backend/pkg/libarcane/imageupdate/digest_test.go @@ -3,7 +3,7 @@ package imageupdate import ( "testing" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/backend/pkg/libarcane/imageupdate/labels.go b/backend/pkg/libarcane/imageupdate/labels.go index fe0d28794c..c9e2d56096 100644 --- a/backend/pkg/libarcane/imageupdate/labels.go +++ b/backend/pkg/libarcane/imageupdate/labels.go @@ -3,12 +3,16 @@ package imageupdate import "strings" const ( - // Core labels + // LabelArcane and the constants below are the core Arcane container labels. LabelArcane = "com.getarcaneapp.arcane" // Identifies the Arcane container itself LabelArcaneAgent = "com.getarcaneapp.arcane.agent" // Identifies an Arcane agent container LabelUpdater = "com.getarcaneapp.arcane.updater" // Enable/disable updates (true/false) - // Dependency labels + // LabelSwarmServiceID and LabelSwarmServiceName identify Docker Swarm task containers. + LabelSwarmServiceID = "com.docker.swarm.service.id" + LabelSwarmServiceName = "com.docker.swarm.service.name" + + // LabelDependsOn and the constants below are update-dependency labels. LabelDependsOn = "com.getarcaneapp.arcane.depends-on" // Comma-separated list of container names this depends on LabelStopSignal = "com.getarcaneapp.arcane.stop-signal" // Custom stop signal (e.g., SIGINT) ) @@ -62,6 +66,11 @@ func IsUpdateDisabled(labels map[string]string) bool { return false } +// IsSwarmTask checks if the labels identify a Docker Swarm task container. +func IsSwarmTask(labels map[string]string) bool { + return hasNonEmptyLabelInternal(labels, LabelSwarmServiceID) || hasNonEmptyLabelInternal(labels, LabelSwarmServiceName) +} + // GetStopSignal returns the custom stop signal if set, otherwise empty string func GetStopSignal(labels map[string]string) string { if labels == nil { @@ -89,6 +98,20 @@ func hasTruthyLabelInternal(labels map[string]string, target string) bool { return false } +func hasNonEmptyLabelInternal(labels map[string]string, target string) bool { + if labels == nil { + return false + } + + for k, v := range labels { + if strings.EqualFold(k, target) && strings.TrimSpace(v) != "" { + return true + } + } + + return false +} + func isTruthyLabelValueInternal(v string) bool { switch strings.TrimSpace(strings.ToLower(v)) { case "true", "1", "yes", "on": diff --git a/backend/pkg/libarcane/imageupdate/labels_test.go b/backend/pkg/libarcane/imageupdate/labels_test.go index d51aeae3cf..2757b36e63 100644 --- a/backend/pkg/libarcane/imageupdate/labels_test.go +++ b/backend/pkg/libarcane/imageupdate/labels_test.go @@ -259,6 +259,58 @@ func TestIsUpdateDisabled(t *testing.T) { } } +func TestIsSwarmTask(t *testing.T) { + tests := []struct { + name string + labels map[string]string + want bool + }{ + { + name: "nil labels", + labels: nil, + want: false, + }, + { + name: "empty labels", + labels: map[string]string{}, + want: false, + }, + { + name: "unrelated labels", + labels: map[string]string{"com.example.service": "api"}, + want: false, + }, + { + name: "swarm service id", + labels: map[string]string{LabelSwarmServiceID: "service-id"}, + want: true, + }, + { + name: "swarm service name", + labels: map[string]string{LabelSwarmServiceName: "web"}, + want: true, + }, + { + name: "case insensitive label key", + labels: map[string]string{"COM.DOCKER.SWARM.SERVICE.ID": "service-id"}, + want: true, + }, + { + name: "empty swarm label value", + labels: map[string]string{LabelSwarmServiceID: " "}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsSwarmTask(tt.labels); got != tt.want { + t.Errorf("IsSwarmTask() = %v, want %v", got, tt.want) + } + }) + } +} + func TestShouldDisableArcaneServerRedeploy(t *testing.T) { tests := []struct { name string diff --git a/backend/pkg/libarcane/imageupdate/logfile.go b/backend/pkg/libarcane/imageupdate/logfile.go index 8984d52334..7d322f58ab 100644 --- a/backend/pkg/libarcane/imageupdate/logfile.go +++ b/backend/pkg/libarcane/imageupdate/logfile.go @@ -2,6 +2,7 @@ package imageupdate import ( "context" + "errors" "fmt" "io" "log/slog" @@ -130,11 +131,11 @@ func formatSlogValueInternal(v slog.Value) string { // SetupMessageOnlyLogFile configures slog to write structured logs to stdout and a // message-only format to a file under dataDir. // -// The file format is: key=value key=value ... (no time/level/source) to keep +// The file format is: key=value ... (no time/level/source) to keep // upgrade logs concise for end users. func SetupMessageOnlyLogFile(dataDir string, filePrefix string, minLevel slog.Level) (*os.File, error) { if strings.TrimSpace(dataDir) == "" { - return nil, fmt.Errorf("dataDir is required") + return nil, errors.New("dataDir is required") } if strings.TrimSpace(filePrefix) == "" { filePrefix = "arcane-upgrade" diff --git a/backend/pkg/libarcane/imageupdate/registry_http.go b/backend/pkg/libarcane/imageupdate/registry_http.go index 79be592c92..8414c2587e 100644 --- a/backend/pkg/libarcane/imageupdate/registry_http.go +++ b/backend/pkg/libarcane/imageupdate/registry_http.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -12,8 +13,8 @@ import ( "strings" "time" + ref "github.com/distribution/reference" "github.com/getarcaneapp/arcane/backend/pkg/libarcane/registryauth" - ref "go.podman.io/image/v5/docker/reference" ) const defaultRegistryHost = "registry-1.docker.io" @@ -197,7 +198,7 @@ func FetchDigest(ctx context.Context, registryHost, repository, tag string, cred digest := extractDigestFromHeadersInternal(resp.Header) if digest == "" { - return "", fmt.Errorf("no digest header found in response") + return "", errors.New("no digest header found in response") } return digest, nil @@ -206,7 +207,7 @@ func FetchDigest(ctx context.Context, registryHost, repository, tag string, cred func fetchRateLimitWithTokenAuthInternal(ctx context.Context, httpClient *http.Client, registryHost, repository, tag, challenge string, credential *Credentials) (*RateLimitInfo, error) { realm, service := parseWWWAuthInternal(challenge) if realm == "" { - return nil, fmt.Errorf("no auth realm found") + return nil, errors.New("no auth realm found") } if err := validateAuthRealmInternal(registryHost, realm); err != nil { return nil, err @@ -233,7 +234,7 @@ func fetchRateLimitWithTokenAuthInternal(ctx context.Context, httpClient *http.C func fetchWithTokenAuthInternal(ctx context.Context, httpClient *http.Client, registryHost, repository, tag, challenge string, credential *Credentials) (string, error) { realm, service := parseWWWAuthInternal(challenge) if realm == "" { - return "", fmt.Errorf("no auth realm found") + return "", errors.New("no auth realm found") } if err := validateAuthRealmInternal(registryHost, realm); err != nil { return "", err @@ -256,7 +257,7 @@ func fetchWithTokenAuthInternal(ctx context.Context, httpClient *http.Client, re digest := extractDigestFromHeadersInternal(resp.Header) if digest == "" { - return "", fmt.Errorf("no digest header found in authenticated response") + return "", errors.New("no digest header found in authenticated response") } return digest, nil @@ -287,7 +288,7 @@ func fetchRegistryTokenInternal(ctx context.Context, httpClient *http.Client, au req.SetBasicAuth(strings.TrimSpace(credential.Username), strings.TrimSpace(credential.Token)) } - resp, err := httpClient.Do(req) //nolint:gosec // authURL comes from the registry challenge for the current image + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("token request failed: %w", err) } @@ -310,7 +311,7 @@ func fetchRegistryTokenInternal(ctx context.Context, httpClient *http.Client, au token = strings.TrimSpace(tokenResponse.Legacy) } if token == "" { - return "", fmt.Errorf("no token in response") + return "", errors.New("no token in response") } return token, nil @@ -325,7 +326,7 @@ func manifestRequestInternal(ctx context.Context, httpClient *http.Client, regis } addManifestRequestHeadersInternal(req, authHeader) - resp, err := httpClient.Do(req) //nolint:gosec // manifestURL is derived from the normalized image reference + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("manifest request failed: %w", err) } @@ -341,7 +342,7 @@ func manifestRequestInternal(ctx context.Context, httpClient *http.Client, regis } addManifestRequestHeadersInternal(getReq, authHeader) - getResp, err := httpClient.Do(getReq) //nolint:gosec // manifestURL is derived from the normalized image reference + getResp, err := httpClient.Do(getReq) if err != nil { return nil, fmt.Errorf("manifest fallback request failed: %w", err) } @@ -424,18 +425,18 @@ func extractDigestFromHeadersInternal(headers http.Header) string { } func extractRateLimitFromHeadersInternal(headers http.Header) (*RateLimitInfo, error) { - limit, limitWindow, limitErr := parseRateLimitHeaderInternal(headers.Get("RateLimit-Limit")) + limit, limitWindow, limitErr := parseRateLimitHeaderInternal(headers.Get("Ratelimit-Limit")) if limitErr != nil { return nil, fmt.Errorf("parse RateLimit-Limit: %w", limitErr) } - remaining, remainingWindow, remainingErr := parseRateLimitHeaderInternal(headers.Get("RateLimit-Remaining")) + remaining, remainingWindow, remainingErr := parseRateLimitHeaderInternal(headers.Get("Ratelimit-Remaining")) if remainingErr != nil { return nil, fmt.Errorf("parse RateLimit-Remaining: %w", remainingErr) } if limit == nil && remaining == nil { - return nil, fmt.Errorf("rate limit headers not returned") + return nil, errors.New("rate limit headers not returned") } windowSeconds := limitWindow @@ -445,8 +446,7 @@ func extractRateLimitFromHeadersInternal(headers http.Header) (*RateLimitInfo, e var used *int if limit != nil && remaining != nil { - value := *limit - *remaining - used = &value + used = new(*limit - *remaining) } return &RateLimitInfo{ @@ -454,7 +454,7 @@ func extractRateLimitFromHeadersInternal(headers http.Header) (*RateLimitInfo, e Remaining: remaining, Used: used, WindowSeconds: windowSeconds, - Source: strings.TrimSpace(headers.Get("Docker-RateLimit-Source")), + Source: strings.TrimSpace(headers.Get("Docker-Ratelimit-Source")), }, nil } @@ -494,7 +494,7 @@ func parseRateLimitHeaderInternal(value string) (*int, *int, error) { func parsePositiveIntInternal(value string) (*int, error) { if value == "" { - return nil, fmt.Errorf("empty integer value") + return nil, errors.New("empty integer value") } parsed, err := strconv.Atoi(value) @@ -587,7 +587,7 @@ func validateAuthRealmInternal(registryHost, realm string) error { registry := normalizeAuthRealmHostInternal(registryHost) if realmHost == "" { - return fmt.Errorf("invalid auth realm host") + return errors.New("invalid auth realm host") } if realmHost == registry { return nil diff --git a/backend/pkg/libarcane/imageupdate/registry_http_test.go b/backend/pkg/libarcane/imageupdate/registry_http_test.go index 35218ef3fe..db6ea3959e 100644 --- a/backend/pkg/libarcane/imageupdate/registry_http_test.go +++ b/backend/pkg/libarcane/imageupdate/registry_http_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/backend/pkg/libarcane/imageupdate/sorter.go b/backend/pkg/libarcane/imageupdate/sorter.go index b8020b9a14..187a4a72ad 100644 --- a/backend/pkg/libarcane/imageupdate/sorter.go +++ b/backend/pkg/libarcane/imageupdate/sorter.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" ) @@ -127,7 +128,7 @@ func ExtractContainerDeps(ctx context.Context, dcli *client.Client, cnt containe c := ContainerWithDeps{ Container: cnt, Inspect: inspect, - Name: ExtractContainerName(cnt), + Name: dockerutil.ContainerSummaryName(cnt), } // Extract Docker links @@ -212,15 +213,3 @@ func hasMarkedDependencyInternal(markedForRestart map[string]bool, deps []string return false } - -// ExtractContainerName extracts a clean container name from the summary -func ExtractContainerName(cnt container.Summary) string { - if len(cnt.Names) > 0 { - n := cnt.Names[0] - if strings.HasPrefix(n, "/") { - return n[1:] - } - return n - } - return cnt.ID[:12] -} diff --git a/backend/pkg/libarcane/imageupdate/sorter_test.go b/backend/pkg/libarcane/imageupdate/sorter_test.go index deaf83db93..2c4b85b6c8 100644 --- a/backend/pkg/libarcane/imageupdate/sorter_test.go +++ b/backend/pkg/libarcane/imageupdate/sorter_test.go @@ -351,40 +351,3 @@ func TestUpdateImplicitRestart(t *testing.T) { }) } } - -func TestExtractContainerName(t *testing.T) { - tests := []struct { - name string - cnt container.Summary - want string - }{ - { - name: "single name with slash", - cnt: container.Summary{Names: []string{"/myapp"}, ID: "abc123"}, - want: "myapp", - }, - { - name: "single name without slash", - cnt: container.Summary{Names: []string{"myapp"}, ID: "abc123"}, - want: "myapp", - }, - { - name: "multiple names - uses first", - cnt: container.Summary{Names: []string{"/myapp", "/myapp-alias"}, ID: "abc123"}, - want: "myapp", - }, - { - name: "no names - falls back to short ID", - cnt: container.Summary{Names: []string{}, ID: "abc123456789"}, - want: "abc123456789", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ExtractContainerName(tt.cnt); got != tt.want { - t.Errorf("ExtractContainerName() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/backend/pkg/libarcane/inspect_compat.go b/backend/pkg/libarcane/inspect_compat.go index d0715e403e..70a06172aa 100644 --- a/backend/pkg/libarcane/inspect_compat.go +++ b/backend/pkg/libarcane/inspect_compat.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -55,7 +56,7 @@ func (c *inspectCompatibilityClient) NetworkList(ctx context.Context, options cl // JSON when the primary typed decode fails on a ParseAddr-style CIDR issue. func ContainerInspectWithCompatibility(ctx context.Context, apiClient client.APIClient, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) { if apiClient == nil { - return client.ContainerInspectResult{}, fmt.Errorf("docker api client is nil") + return client.ContainerInspectResult{}, errors.New("docker api client is nil") } result, err := apiClient.ContainerInspect(ctx, containerID, options) @@ -93,7 +94,7 @@ func ContainerInspectWithCompatibility(ctx context.Context, apiClient client.API // JSON when the primary typed decode fails on a ParseAddr-style CIDR issue. func NetworkInspectWithCompatibility(ctx context.Context, apiClient client.APIClient, networkID string, options client.NetworkInspectOptions) (client.NetworkInspectResult, error) { if apiClient == nil { - return client.NetworkInspectResult{}, fmt.Errorf("docker api client is nil") + return client.NetworkInspectResult{}, errors.New("docker api client is nil") } result, err := apiClient.NetworkInspect(ctx, networkID, options) @@ -134,7 +135,7 @@ func NetworkInspectWithCompatibility(ctx context.Context, apiClient client.APICl // when the primary typed decode fails on a ParseAddr-style CIDR issue. func NetworkListWithCompatibility(ctx context.Context, apiClient client.APIClient, options client.NetworkListOptions) (client.NetworkListResult, error) { if apiClient == nil { - return client.NetworkListResult{}, fmt.Errorf("docker api client is nil") + return client.NetworkListResult{}, errors.New("docker api client is nil") } result, err := apiClient.NetworkList(ctx, options) @@ -170,7 +171,7 @@ func NetworkListWithCompatibility(ctx context.Context, apiClient client.APIClien func fetchDockerAPIJSONInternal(ctx context.Context, apiClient client.APIClient, resourcePath string, query url.Values) ([]byte, error) { dialer := apiClient.Dialer() if dialer == nil { - return nil, fmt.Errorf("docker api client does not expose a dialer") + return nil, errors.New("docker api client does not expose a dialer") } reqURL := &url.URL{ diff --git a/backend/pkg/libarcane/internal_resource.go b/backend/pkg/libarcane/internal_resource.go index e4aeb519cc..a2325209d5 100644 --- a/backend/pkg/libarcane/internal_resource.go +++ b/backend/pkg/libarcane/internal_resource.go @@ -2,19 +2,15 @@ package libarcane import "strings" -// Internal containers indicate containers used for arcanes utilties, ie: temp containers used for viewing files for volumes etc -const ( - InternalResourceLabel = "com.getarcaneapp.internal.resource" - // Deprecated - Legacy label. Use InternalResourceLabel instead. - legacyInternalContainerLabel = "com.getarcaneapp.internal.container" -) +// InternalResourceLabel marks containers used for Arcane utilities, e.g. temp containers used for viewing volume files. +const InternalResourceLabel = "com.getarcaneapp.internal.resource" func IsInternalContainer(labels map[string]string) bool { if labels == nil { return false } for k, v := range labels { - if strings.EqualFold(k, InternalResourceLabel) || strings.EqualFold(k, legacyInternalContainerLabel) { + if strings.EqualFold(k, InternalResourceLabel) { switch strings.TrimSpace(strings.ToLower(v)) { case "true", "1", "yes", "on": return true diff --git a/backend/pkg/libarcane/libbuild/build_context.go b/backend/pkg/libarcane/libbuild/build_context.go index f981665948..a4aae4d175 100644 --- a/backend/pkg/libarcane/libbuild/build_context.go +++ b/backend/pkg/libarcane/libbuild/build_context.go @@ -1,7 +1,7 @@ package libbuild import ( - "fmt" + "errors" "net/url" "path" "strings" @@ -37,13 +37,13 @@ func ParseGitBuildContextSource(raw string) (*GitBuildContextSource, bool, error fragment = strings.TrimSpace(fragment) if fragment == "" { - return nil, true, fmt.Errorf("git build context fragment cannot be empty") + return nil, true, errors.New("git build context fragment cannot be empty") } ref, subdir, hasSubdir := strings.Cut(fragment, ":") ref = strings.TrimSpace(ref) if ref == "" { - return nil, true, fmt.Errorf("git build context ref cannot be empty") + return nil, true, errors.New("git build context ref cannot be empty") } source.Ref = ref @@ -53,15 +53,15 @@ func ParseGitBuildContextSource(raw string) (*GitBuildContextSource, bool, error subdir = strings.TrimSpace(subdir) if subdir == "" { - return nil, true, fmt.Errorf("git build context subdir cannot be empty") + return nil, true, errors.New("git build context subdir cannot be empty") } if strings.HasPrefix(subdir, "/") { - return nil, true, fmt.Errorf("git build context subdir must be relative") + return nil, true, errors.New("git build context subdir must be relative") } cleanSubdir := path.Clean(subdir) if cleanSubdir == "." || cleanSubdir == ".." || strings.HasPrefix(cleanSubdir, "../") { - return nil, true, fmt.Errorf("git build context subdir must stay within the repository") + return nil, true, errors.New("git build context subdir must stay within the repository") } source.Subdir = cleanSubdir diff --git a/backend/pkg/libarcane/libbuild/builder_buildkit.go b/backend/pkg/libarcane/libbuild/builder_buildkit.go index 87ff21d2ec..ccb57ba909 100644 --- a/backend/pkg/libarcane/libbuild/builder_buildkit.go +++ b/backend/pkg/libarcane/libbuild/builder_buildkit.go @@ -133,14 +133,14 @@ func (b *builder) buildSolveOptInternal(ctx context.Context, req imagetypes.Buil frontendAttrs["platform"] = strings.Join(req.Platforms, ",") } for key, val := range req.BuildArgs { - frontendAttrs[fmt.Sprintf("build-arg:%s", key)] = val + frontendAttrs["build-arg:"+key] = val } for key, val := range req.Labels { k := strings.TrimSpace(key) if k == "" { continue } - frontendAttrs[fmt.Sprintf("label:%s", k)] = val + frontendAttrs["label:"+k] = val } solveOpt := buildkit.SolveOpt{ diff --git a/backend/pkg/libarcane/libbuild/builder_buildkit_local.go b/backend/pkg/libarcane/libbuild/builder_buildkit_local.go index dc2656270e..83af481c71 100644 --- a/backend/pkg/libarcane/libbuild/builder_buildkit_local.go +++ b/backend/pkg/libarcane/libbuild/builder_buildkit_local.go @@ -3,6 +3,7 @@ package libbuild import ( "bufio" "context" + "errors" "fmt" "os" "strings" @@ -15,7 +16,7 @@ import ( func (b *builder) newLocalBuildkitSessionInternal(ctx context.Context) (*buildSession, error) { if b.dockerClientProvider == nil { - return nil, fmt.Errorf("docker service not available") + return nil, errors.New("docker service not available") } dockerClient, err := b.dockerClientProvider.GetClient(ctx) diff --git a/backend/pkg/libarcane/libbuild/builder_docker.go b/backend/pkg/libarcane/libbuild/builder_docker.go index f2855f659f..9718c991a0 100644 --- a/backend/pkg/libarcane/libbuild/builder_docker.go +++ b/backend/pkg/libarcane/libbuild/builder_docker.go @@ -25,6 +25,7 @@ import ( type dockerBuildInput struct { buildFilesystemInput + platform string buildArgs map[string]*string labels map[string]string @@ -140,7 +141,7 @@ func prepareDockerBuildInputInternal(req imagetypes.BuildRequest) (dockerBuildIn } if len(req.Platforms) > 1 { - return dockerBuildInput{}, true, fmt.Errorf("docker build fallback does not support multi-platform builds") + return dockerBuildInput{}, true, errors.New("docker build fallback does not support multi-platform builds") } platform := "" @@ -360,7 +361,7 @@ func (b *builder) pushDockerImagesInternal( writeProgressEventInternal(progressWriter, imagetypes.ProgressEvent{ Type: "build", Service: serviceName, - Status: fmt.Sprintf("pushing %s", tag), + Status: "pushing " + tag, }) pushOptions := dockerclient.ImagePushOptions{} if b.registryAuthProvider != nil { @@ -369,7 +370,7 @@ func (b *builder) pushDockerImagesInternal( writeProgressEventInternal(progressWriter, imagetypes.ProgressEvent{ Type: "build", Service: serviceName, - Status: fmt.Sprintf("registry auth unavailable for %s", tag), + Status: "registry auth unavailable for " + tag, }) } else if authHeader != "" { pushOptions.RegistryAuth = authHeader diff --git a/backend/pkg/libarcane/logstream/logstream.go b/backend/pkg/libarcane/logstream/logstream.go new file mode 100644 index 0000000000..1487ec6313 --- /dev/null +++ b/backend/pkg/libarcane/logstream/logstream.go @@ -0,0 +1,152 @@ +// Package logstream captures the application's own slog output into a bounded +// in-memory ring buffer and fans it out to live subscribers. It backs the +// diagnostics endpoints' "recent logs" backlog and the live backend-log +// WebSocket stream. It deliberately depends only on the shared types package so +// both bootstrap (which installs the slog handler) and the API handlers (which +// read it) can use it without an import cycle. +package logstream + +import ( + "context" + "log/slog" + "sync" + + "github.com/getarcaneapp/arcane/types/system" +) + +// defaultRingCapacity is the number of recent log entries retained in memory. +const defaultRingCapacity = 1000 + +// subscriberBuffer is the per-subscriber channel buffer. Slow consumers have +// entries dropped rather than blocking the logger. +const subscriberBuffer = 256 + +// Entry is a single captured log record. +type Entry = system.LogEntry + +// Broadcaster keeps a bounded ring buffer of recent log entries and fans new +// entries out to any active subscribers. It is safe for concurrent use. +type Broadcaster struct { + mu sync.Mutex + buf []Entry + start int + size int + capN int + subs map[chan Entry]struct{} +} + +// New returns a Broadcaster retaining up to capacity recent entries. +func New(capacity int) *Broadcaster { + if capacity <= 0 { + capacity = defaultRingCapacity + } + return &Broadcaster{ + buf: make([]Entry, capacity), + capN: capacity, + subs: make(map[chan Entry]struct{}), + } +} + +// Append records an entry in the ring buffer and delivers it to subscribers. +// Delivery is non-blocking; a subscriber whose buffer is full drops the entry. +func (b *Broadcaster) Append(e Entry) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.size < b.capN { + b.buf[(b.start+b.size)%b.capN] = e + b.size++ + } else { + b.buf[b.start] = e + b.start = (b.start + 1) % b.capN + } + + // Non-blocking sends under the lock: cancel() also takes the lock before + // closing a subscriber channel, so we never send on a closed channel. + for ch := range b.subs { + select { + case ch <- e: + default: + } + } +} + +// Recent returns the buffered entries in chronological order (oldest first). +func (b *Broadcaster) Recent() []Entry { + b.mu.Lock() + defer b.mu.Unlock() + + out := make([]Entry, b.size) + for i := range b.size { + out[i] = b.buf[(b.start+i)%b.capN] + } + return out +} + +// Subscribe registers a new live subscriber. It returns a receive-only channel +// of subsequent entries and a cancel func that unsubscribes and closes the +// channel. cancel is idempotent. +func (b *Broadcaster) Subscribe() (<-chan Entry, func()) { + ch := make(chan Entry, subscriberBuffer) + b.mu.Lock() + b.subs[ch] = struct{}{} + b.mu.Unlock() + + var once sync.Once + cancel := func() { + once.Do(func() { + b.mu.Lock() + delete(b.subs, ch) + close(ch) + b.mu.Unlock() + }) + } + return ch, cancel +} + +var defaultBroadcaster = New(defaultRingCapacity) + +// Default returns the package-level Broadcaster singleton. +func Default() *Broadcaster { return defaultBroadcaster } + +// slogHandler wraps a base slog.Handler, forwarding every record to it while +// also appending it to a Broadcaster. +type slogHandler struct { + base slog.Handler + b *Broadcaster +} + +// NewSlogHandler returns a slog.Handler that tees records to base and to b. +func NewSlogHandler(base slog.Handler, b *Broadcaster) slog.Handler { + return &slogHandler{base: base, b: b} +} + +func (h *slogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.base.Enabled(ctx, level) +} + +func (h *slogHandler) Handle(ctx context.Context, r slog.Record) error { + var attrs map[string]any + if r.NumAttrs() > 0 { + attrs = make(map[string]any, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + } + h.b.Append(Entry{ + Time: r.Time, + Level: r.Level.String(), + Message: r.Message, + Attrs: attrs, + }) + return h.base.Handle(ctx, r) +} + +func (h *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &slogHandler{base: h.base.WithAttrs(attrs), b: h.b} +} + +func (h *slogHandler) WithGroup(name string) slog.Handler { + return &slogHandler{base: h.base.WithGroup(name), b: h.b} +} diff --git a/backend/pkg/libarcane/registryauth/helpers.go b/backend/pkg/libarcane/registryauth/helpers.go index 8bf6258f10..bcf4d651fb 100644 --- a/backend/pkg/libarcane/registryauth/helpers.go +++ b/backend/pkg/libarcane/registryauth/helpers.go @@ -5,9 +5,9 @@ import ( "sort" "strings" + ref "github.com/distribution/reference" dockerauthconfig "github.com/moby/moby/api/pkg/authconfig" dockerregistry "github.com/moby/moby/api/types/registry" - ref "go.podman.io/image/v5/docker/reference" ) func GetRegistryAddress(imageRef string) (string, error) { @@ -95,7 +95,7 @@ func DecodeAuthHeader(authEncoded string) (dockerregistry.AuthConfig, error) { return *cfg, nil } -func RegistryAuthLookupKeys(url string) []string { +func LookupKeys(url string) []string { normalizedHost := NormalizeRegistryForComparison(url) if normalizedHost == "" { return nil diff --git a/backend/pkg/libarcane/registryauth/helpers_test.go b/backend/pkg/libarcane/registryauth/helpers_test.go index eb53641756..771a5ec0db 100644 --- a/backend/pkg/libarcane/registryauth/helpers_test.go +++ b/backend/pkg/libarcane/registryauth/helpers_test.go @@ -81,7 +81,7 @@ func TestDecodeAuthHeader_InvalidInput(t *testing.T) { } func TestRegistryAuthLookupKeys(t *testing.T) { - assert.Equal(t, []string{"ghcr.io"}, RegistryAuthLookupKeys("https://GHCR.IO/")) - assert.Equal(t, []string{"docker.io", "index.docker.io", "registry-1.docker.io"}, RegistryAuthLookupKeys("https://index.docker.io/v1/")) - assert.Nil(t, RegistryAuthLookupKeys(" ")) + assert.Equal(t, []string{"ghcr.io"}, LookupKeys("https://GHCR.IO/")) + assert.Equal(t, []string{"docker.io", "index.docker.io", "registry-1.docker.io"}, LookupKeys("https://index.docker.io/v1/")) + assert.Nil(t, LookupKeys(" ")) } diff --git a/backend/pkg/libarcane/startup/runtime_identity.go b/backend/pkg/libarcane/startup/runtime_identity.go index 1fa81b518d..b148599e6c 100644 --- a/backend/pkg/libarcane/startup/runtime_identity.go +++ b/backend/pkg/libarcane/startup/runtime_identity.go @@ -17,6 +17,8 @@ const ( defaultDatabaseURL = "file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" defaultDockerConfigDir = "/app/data/.docker" defaultDockerSocketPath = "/var/run/docker.sock" + defaultRuntimeUID = 65532 + defaultRuntimeGID = 65532 mountInfoPath = "/proc/self/mountinfo" ) @@ -65,8 +67,7 @@ func ApplyRequestedRuntimeIdentity(ctx context.Context, cfg *RuntimeIdentityConf runtimeUID := req.UID runtimeGID := req.GID - // Avoid re-execing forever when the requested runtime identity is already active, - // including explicit root requests such as PUID=0/PGID=0. + // Avoid re-execing forever when the requested runtime identity is already active. if os.Geteuid() == runtimeUID && os.Getegid() == runtimeGID { if err := ensureRuntimeDockerConfigInternal(cfg, os.Setenv, runtimeUID, runtimeGID); err != nil { return err @@ -108,11 +109,11 @@ func loadRuntimeIdentityRequestInternal(cfg *RuntimeIdentityConfig) (runtimeIden pgid := strings.TrimSpace(cfg.PGID) if puid == "" && pgid == "" { - return runtimeIdentityRequest{}, "", nil + return defaultRuntimeIdentityRequestInternal(cfg.DockerHost), "", nil } if puid == "" || pgid == "" { - return runtimeIdentityRequest{}, "PUID and PGID must both be set to enable non-root mode; continuing with default runtime user", nil + return defaultRuntimeIdentityRequestInternal(cfg.DockerHost), "PUID and PGID must both be set to override the default non-root runtime user; continuing with the default non-root runtime user", nil } uid, credentialUID, err := parseRuntimeIdentityValueInternal(puid, "PUID") @@ -135,6 +136,17 @@ func loadRuntimeIdentityRequestInternal(cfg *RuntimeIdentityConfig) (runtimeIden }, "", nil } +func defaultRuntimeIdentityRequestInternal(dockerHost string) runtimeIdentityRequest { + return runtimeIdentityRequest{ + Enabled: true, + UID: defaultRuntimeUID, + GID: defaultRuntimeGID, + CredentialUID: uint32(defaultRuntimeUID), + CredentialGID: uint32(defaultRuntimeGID), + DockerHost: dockerHost, + } +} + func runtimeDockerConfigDirInternal(cfg *RuntimeIdentityConfig) string { if cfg == nil { cfg = &RuntimeIdentityConfig{} @@ -257,6 +269,9 @@ func prepareWritablePathsWithRootsInternal(uid int, gid int, mountpoints map[str for _, entry := range entries { entryPath := filepath.Join(dataDirectory, entry.Name()) if _, mounted := mountpoints[entryPath]; mounted { + if err := os.Lchown(entryPath, uid, gid); err != nil { + return fmt.Errorf("chown mounted %s: %w", entryPath, err) + } continue } if projectsDir != "" && filepath.Clean(entryPath) == projectsDir { @@ -271,6 +286,9 @@ func prepareWritablePathsWithRootsInternal(uid int, gid int, mountpoints map[str } if _, mounted := mountpoints[buildsDirectory]; mounted { + if err := os.Lchown(buildsDirectory, uid, gid); err != nil { + return fmt.Errorf("chown mounted builds directory: %w", err) + } return nil } @@ -302,12 +320,12 @@ func ensureSQLiteFilesExistInternal(databaseURL string) error { // prepareWritablePathsInternal is not called. dir := filepath.Dir(sqlitePath) if dir != "" && dir != "." { - if err := os.MkdirAll(dir, pkgutils.DirPerm); err != nil { //nolint:gosec // path is derived from the configured SQLite DSN, not user input + if err := os.MkdirAll(dir, pkgutils.DirPerm); err != nil { return fmt.Errorf("create sqlite directory %s: %w", dir, err) } } - file, err := os.OpenFile(sqlitePath, os.O_CREATE|os.O_RDWR, pkgutils.FilePerm) //nolint:gosec // path is derived from the configured SQLite DSN + file, err := os.OpenFile(sqlitePath, os.O_CREATE|os.O_RDWR, pkgutils.FilePerm) if err != nil { return fmt.Errorf("create sqlite file %s: %w", sqlitePath, err) } @@ -367,7 +385,7 @@ func chownRecursiveInternal(path string, uid int, gid int, mountpoints map[strin } return filepath.SkipDir } - //nolint:gosec // currentPath comes from fixed container paths under /app/data or /builds + return lchownFn(currentPath, uid, gid) }) } diff --git a/backend/pkg/libarcane/startup/runtime_identity_test.go b/backend/pkg/libarcane/startup/runtime_identity_test.go index 8b26d43ca7..cf64b7e24d 100644 --- a/backend/pkg/libarcane/startup/runtime_identity_test.go +++ b/backend/pkg/libarcane/startup/runtime_identity_test.go @@ -11,18 +11,29 @@ import ( ) func TestLoadRuntimeIdentityRequest(t *testing.T) { - t.Run("disabled when unset", func(t *testing.T) { - req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{}) + t.Run("enabled with default non-root runtime when unset", func(t *testing.T) { + req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{ + DockerHost: "unix:///tmp/docker.sock", + }) require.NoError(t, err) require.Empty(t, warning) - require.False(t, req.Enabled) + require.True(t, req.Enabled) + require.Equal(t, defaultRuntimeUID, req.UID) + require.Equal(t, defaultRuntimeGID, req.GID) + require.Equal(t, uint32(defaultRuntimeUID), req.CredentialUID) + require.Equal(t, uint32(defaultRuntimeGID), req.CredentialGID) + require.Equal(t, "unix:///tmp/docker.sock", req.DockerHost) }) t.Run("warning when partial config", func(t *testing.T) { req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{PUID: "1001"}) require.NoError(t, err) require.Contains(t, warning, "PUID and PGID must both be set") - require.False(t, req.Enabled) + require.True(t, req.Enabled) + require.Equal(t, defaultRuntimeUID, req.UID) + require.Equal(t, defaultRuntimeGID, req.GID) + require.Equal(t, uint32(defaultRuntimeUID), req.CredentialUID) + require.Equal(t, uint32(defaultRuntimeGID), req.CredentialGID) }) t.Run("error when invalid numeric value", func(t *testing.T) { @@ -75,12 +86,14 @@ func TestRuntimeDockerConfigDir(t *testing.T) { require.Equal(t, "/custom/docker-config", dir) }) - t.Run("root default runtime leaves runtime identity disabled", func(t *testing.T) { + t.Run("default runtime uses non-root identity", func(t *testing.T) { req, warning, err := loadRuntimeIdentityRequestInternal(&RuntimeIdentityConfig{}) require.NoError(t, err) require.Empty(t, warning) - require.False(t, req.Enabled) + require.True(t, req.Enabled) + require.Equal(t, defaultRuntimeUID, req.UID) + require.Equal(t, defaultRuntimeGID, req.GID) }) } diff --git a/backend/pkg/libarcane/startup/startup.go b/backend/pkg/libarcane/startup/startup.go index eae84c7b9f..29c8be8f5e 100644 --- a/backend/pkg/libarcane/startup/startup.go +++ b/backend/pkg/libarcane/startup/startup.go @@ -2,11 +2,7 @@ package startup import ( "context" - "fmt" "log/slog" - "strconv" - "strings" - "time" "github.com/getarcaneapp/arcane/backend/buildables" ) @@ -50,11 +46,6 @@ type SettingsPruner interface { PruneUnknownSettings(ctx context.Context) error } -type IntervalMigrationItem struct { - ID string - RawValue string -} - func InitializeDefaultSettings(ctx context.Context, cfg *RuntimeConfig, settingsMgr SettingsManager) { slog.InfoContext(ctx, "Ensuring default settings are initialized") @@ -83,19 +74,40 @@ func TestDockerConnection(ctx context.Context, testFunc func(context.Context) er } } +func CleanupOrphanedVolumeHelpers(ctx context.Context, cleanupFunc func(context.Context) (int, error)) { + if cleanupFunc == nil { + return + } + + slog.InfoContext(ctx, "Checking for orphaned volume helper containers from previous runs") + + removedCount, err := cleanupFunc(ctx) + if err != nil { + slog.WarnContext(ctx, "Failed to clean up orphaned volume helper containers during startup", "error", err.Error()) + return + } + + slog.InfoContext(ctx, "Orphaned volume helper cleanup completed", "removed_count", removedCount) +} + func InitializeNonAgentFeatures( ctx context.Context, cfg *RuntimeConfig, + ensureBuiltInRolesFunc func(context.Context) error, createAdminFunc func(context.Context) error, reconcileDefaultAdminAPIKeyFunc func(context.Context) error, autoLoginInitFunc func(context.Context) error, - migrateOidcFunc func(context.Context) error, - migrateDiscordFunc func(context.Context) error, ) { if cfg.AgentMode { return } + if ensureBuiltInRolesFunc != nil { + if err := ensureBuiltInRolesFunc(ctx); err != nil { + slog.ErrorContext(ctx, "Failed to seed built-in roles before admin bootstrap", "error", err.Error()) + } + } + if err := createAdminFunc(ctx); err != nil { slog.WarnContext(ctx, "Failed to create default admin user", "error", err.Error()) } @@ -109,20 +121,6 @@ func InitializeNonAgentFeatures( if err := autoLoginInitFunc(ctx); err != nil { slog.WarnContext(ctx, "Failed to initialize auto-login", "error", err.Error()) } - - // Migrate legacy OIDC JSON config to individual fields (runs before env sync) - if migrateOidcFunc != nil { - if err := migrateOidcFunc(ctx); err != nil { - slog.WarnContext(ctx, "Failed to migrate OIDC config to individual fields", "error", err.Error()) - } - } - - // Migrate legacy Discord webhookUrl to separate webhookId and token fields - if migrateDiscordFunc != nil { - if err := migrateDiscordFunc(ctx); err != nil { - slog.WarnContext(ctx, "Failed to migrate Discord webhook config", "error", err.Error()) - } - } } func InitializeAutoLogin(ctx context.Context, cfg *RuntimeConfig) { @@ -132,331 +130,3 @@ func InitializeAutoLogin(ctx context.Context, cfg *RuntimeConfig) { slog.WarnContext(ctx, "⚠️ AUTO-LOGIN IS ENABLED - This is a security risk! Do NOT use in production.", "username", cfg.AutoLoginUsername) slog.WarnContext(ctx, "⚠️ Auto-login will automatically authenticate users without requiring credentials.") } - -func MigrateSchedulerCronValues( - ctx context.Context, - getSettingFunc func(context.Context, string, string) string, - updateSettingFunc func(context.Context, string, string) error, - reloadFunc func(context.Context) error, -) { - migrations := []struct { - key string - defaultUnit time.Duration - }{ - {key: "pollingInterval", defaultUnit: time.Minute}, - {key: "autoUpdateInterval", defaultUnit: time.Minute}, - {key: "scheduledPruneInterval", defaultUnit: time.Minute}, - {key: "environmentHealthInterval", defaultUnit: time.Minute}, - {key: "eventCleanupInterval", defaultUnit: time.Minute}, - {key: "expiredSessionsCleanupInterval", defaultUnit: time.Minute}, - } - - changed := false - for _, item := range migrations { - raw := strings.TrimSpace(getSettingFunc(ctx, item.key, "")) - if raw == "" { - continue - } - - cronValue, shouldUpdate, warn := normalizeSchedulerValueToCron(raw, item.defaultUnit) - if warn != "" { - slog.WarnContext(ctx, warn, "key", item.key, "value", raw) - } - if !shouldUpdate { - continue - } - if cronValue == "" || cronValue == raw { - continue - } - if err := updateSettingFunc(ctx, item.key, cronValue); err != nil { - slog.WarnContext(ctx, "Failed to migrate scheduler setting", "key", item.key, "error", err.Error()) - continue - } - slog.InfoContext(ctx, "Migrated scheduler setting to cron", "key", item.key, "value", cronValue) - changed = true - } - - if changed && reloadFunc != nil { - if err := reloadFunc(ctx); err != nil { - slog.WarnContext(ctx, "Failed to reload settings after scheduler migration", "error", err.Error()) - } - } -} - -func MigrateGitOpsSyncIntervals( - ctx context.Context, - listFunc func(context.Context) ([]IntervalMigrationItem, error), - updateFunc func(context.Context, string, int) error, -) { - if listFunc == nil || updateFunc == nil { - return - } - - items, err := listFunc(ctx) - if err != nil { - slog.WarnContext(ctx, "Failed to load git sync intervals for migration", "error", err.Error()) - return - } - - for _, item := range items { - raw := strings.TrimSpace(item.RawValue) - if raw == "" { - continue - } - minutes, shouldUpdate, warn := normalizeSchedulerValueToMinutes(raw, time.Minute) - if warn != "" { - slog.WarnContext(ctx, warn, "syncId", item.ID, "value", raw) - } - if !shouldUpdate || minutes <= 0 { - continue - } - if err := updateFunc(ctx, item.ID, minutes); err != nil { - slog.WarnContext(ctx, "Failed to migrate git sync interval", "syncId", item.ID, "error", err.Error()) - continue - } - slog.InfoContext(ctx, "Migrated git sync interval", "syncId", item.ID, "minutes", minutes) - } -} - -func normalizeSchedulerValueToCron(raw string, defaultUnit time.Duration) (string, bool, string) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "", false, "" - } - - if strings.HasPrefix(trimmed, "@") { - return trimmed, false, "" - } - - if strings.HasPrefix(trimmed, "CRON_TZ=") || strings.HasPrefix(trimmed, "TZ=") { - parts := strings.Fields(trimmed) - if len(parts) == 6 { - return parts[0] + " 0 " + strings.Join(parts[1:], " "), true, "" - } - return trimmed, false, "" - } - - fields := strings.Fields(trimmed) - if len(fields) == 5 { - return "0 " + trimmed, true, "" - } - if len(fields) >= 6 { - return trimmed, false, "" - } - - duration, ok := parseSchedulerDuration(trimmed, defaultUnit) - if !ok { - return "", false, "" - } - - cronValue, warn := durationToCron(duration) - if cronValue == "" { - return "", false, warn - } - - return cronValue, true, warn -} - -func parseSchedulerDuration(raw string, defaultUnit time.Duration) (time.Duration, bool) { - if raw == "" { - return 0, false - } - if d, err := time.ParseDuration(raw); err == nil { - return d, true - } - value, err := strconv.Atoi(raw) - if err != nil { - return 0, false - } - if defaultUnit <= 0 { - defaultUnit = time.Minute - } - return time.Duration(value) * defaultUnit, true -} - -func durationToCron(d time.Duration) (string, string) { - if d <= 0 { - return "", "" - } - - if d < time.Minute { - seconds := int(d.Seconds()) - if d%time.Second != 0 { - seconds++ - } - if seconds < 1 { - return "", "" - } - if seconds >= 60 { - minutes := (seconds + 59) / 60 - return minutesToCron(minutes), "scheduler interval rounded up to minutes" - } - return fmt.Sprintf("*/%d * * * * *", seconds), "" - } - - if d%time.Minute != 0 { - seconds := int(d.Seconds()) - if d%time.Second != 0 { - seconds++ - } - minutes := (seconds + 59) / 60 - return minutesToCron(minutes), "scheduler interval rounded up to minutes" - } - - minutes := int(d.Minutes()) - return minutesToCron(minutes), "" -} - -func minutesToCron(minutes int) string { - if minutes <= 0 { - return "" - } - if minutes < 60 { - return fmt.Sprintf("0 */%d * * * *", minutes) - } - if minutes%60 == 0 { - hours := minutes / 60 - if hours < 24 { - return fmt.Sprintf("0 0 */%d * * *", hours) - } - if hours%24 == 0 { - days := hours / 24 - return fmt.Sprintf("0 0 0 */%d * *", days) - } - } - return fmt.Sprintf("0 */%d * * * *", minutes) -} - -func normalizeSchedulerValueToMinutes(raw string, defaultUnit time.Duration) (int, bool, string) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return 0, false, "" - } - - if strings.HasPrefix(trimmed, "@") { - return 0, false, "" - } - - if strings.HasPrefix(trimmed, "CRON_TZ=") || strings.HasPrefix(trimmed, "TZ=") { - parts := strings.Fields(trimmed) - if len(parts) >= 6 { - minutes, ok, warn := cronToMinutes(parts[1:]) - if ok { - return minutes, true, warn - } - } - return 0, false, "" - } - - fields := strings.Fields(trimmed) - if len(fields) >= 5 { - if len(fields) == 5 { - fields = append([]string{"0"}, fields...) - } - minutes, ok, warn := cronToMinutes(fields) - if ok { - return minutes, true, warn - } - return 0, false, "" - } - - duration, ok := parseSchedulerDuration(trimmed, defaultUnit) - if !ok { - return 0, false, "" - } - - minutes := int(duration / time.Minute) - warn := "" - if duration%time.Minute != 0 { - minutes++ - warn = "scheduler interval rounded up to minutes" - } - if minutes < 1 { - minutes = 1 - warn = "scheduler interval rounded up to minutes" - } - if strconv.Itoa(minutes) == trimmed { - return minutes, false, warn - } - - return minutes, true, warn -} - -func cronToMinutes(fields []string) (int, bool, string) { - if len(fields) < 6 { - return 0, false, "" - } - - sec, min, hour, day, month, weekday := fields[0], fields[1], fields[2], fields[3], fields[4], fields[5] - - if mins, ok, warn := tryConvertSecondStep(sec, min, hour, day, month, weekday); ok { - return mins, ok, warn - } - if mins, ok, warn := tryConvertMinuteOrHour(sec, min, hour, day, month, weekday); ok { - return mins, ok, warn - } - if mins, ok, warn := tryConvertDayStep(sec, min, hour, day, month, weekday); ok { - return mins, ok, warn - } - - return 0, false, "" -} - -func tryConvertSecondStep(sec, min, hour, day, month, weekday string) (int, bool, string) { - step, ok := parseCronStep(sec) - if !ok { - return 0, false, "" - } - - if min == "*" && hour == "*" && day == "*" && month == "*" && weekday == "*" { - minutes := max((step+59)/60, 1) - warn := "" - if step%60 != 0 { - warn = "scheduler interval rounded up to minutes" - } - return minutes, true, warn - } - return 0, false, "" -} - -func tryConvertMinuteOrHour(sec, min, hour, day, month, weekday string) (int, bool, string) { - if (sec != "0" && sec != "*") || day != "*" || month != "*" || weekday != "*" { - return 0, false, "" - } - - if step, ok := parseCronStep(min); ok && hour == "*" { - return step, true, "" - } - if min == "*" && hour == "*" { - return 1, true, "" - } - if min == "0" { - if step, ok := parseCronStep(hour); ok && day == "*" { - return step * 60, true, "" - } - if hour == "*" { - return 60, true, "" - } - } - return 0, false, "" -} - -func tryConvertDayStep(sec, min, hour, day, month, weekday string) (int, bool, string) { - if (sec == "0" || sec == "*") && min == "0" && hour == "0" && month == "*" && weekday == "*" { - if step, ok := parseCronStep(day); ok { - return step * 1440, true, "" - } - } - return 0, false, "" -} - -func parseCronStep(field string) (int, bool) { - if !strings.HasPrefix(field, "*/") { - return 0, false - } - step, err := strconv.Atoi(strings.TrimPrefix(field, "*/")) - if err != nil || step <= 0 { - return 0, false - } - return step, true -} diff --git a/backend/pkg/libarcane/startup/startup_test.go b/backend/pkg/libarcane/startup/startup_test.go index 7c027eb241..96460c4659 100644 --- a/backend/pkg/libarcane/startup/startup_test.go +++ b/backend/pkg/libarcane/startup/startup_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "testing" - "time" "github.com/stretchr/testify/assert" ) @@ -53,14 +52,14 @@ func TestLoadAgentToken(t *testing.T) { } getSettingFunc := func(ctx context.Context, key string, def string) string { if key == "agentToken" { - return "test-token-123" + return "loaded-token" } return def } LoadAgentToken(ctx, cfg, getSettingFunc) - assert.Equal(t, "test-token-123", cfg.AgentToken) + assert.Equal(t, "loaded-token", cfg.AgentToken) }) t.Run("does not load when not in agent mode", func(t *testing.T) { @@ -68,66 +67,57 @@ func TestLoadAgentToken(t *testing.T) { AgentMode: false, AgentToken: "", } + called := false getSettingFunc := func(ctx context.Context, key string, def string) string { - return "test-token-123" + called = true + return "" } LoadAgentToken(ctx, cfg, getSettingFunc) + assert.False(t, called) assert.Empty(t, cfg.AgentToken) }) - t.Run("does not override existing token", func(t *testing.T) { + t.Run("does not load when token already set", func(t *testing.T) { cfg := &RuntimeConfig{ AgentMode: true, AgentToken: "existing-token", } + called := false getSettingFunc := func(ctx context.Context, key string, def string) string { - return "new-token" - } - - LoadAgentToken(ctx, cfg, getSettingFunc) - - assert.Equal(t, "existing-token", cfg.AgentToken) - }) - - t.Run("handles empty token from database", func(t *testing.T) { - cfg := &RuntimeConfig{ - AgentMode: true, - AgentToken: "", - } - getSettingFunc := func(ctx context.Context, key string, def string) string { + called = true return "" } LoadAgentToken(ctx, cfg, getSettingFunc) - assert.Empty(t, cfg.AgentToken) + assert.False(t, called) + assert.Equal(t, "existing-token", cfg.AgentToken) }) } func TestEnsureEncryptionKey(t *testing.T) { ctx := context.Background() - t.Run("sets key in agent mode", func(t *testing.T) { + t.Run("sets key when in agent mode", func(t *testing.T) { cfg := &RuntimeConfig{ - AgentMode: true, - EncryptionKey: "", + AgentMode: true, + Environment: "production", } ensureKeyFunc := func(ctx context.Context) (string, error) { - return "generated-key", nil + return "secret-key", nil } EnsureEncryptionKey(ctx, cfg, ensureKeyFunc) - assert.Equal(t, "generated-key", cfg.EncryptionKey) + assert.Equal(t, "secret-key", cfg.EncryptionKey) }) - t.Run("sets key in non-production", func(t *testing.T) { + t.Run("sets key when not in production", func(t *testing.T) { cfg := &RuntimeConfig{ - AgentMode: false, - Environment: "development", - EncryptionKey: "", + AgentMode: false, + Environment: "development", } ensureKeyFunc := func(ctx context.Context) (string, error) { return "dev-key", nil @@ -138,25 +128,28 @@ func TestEnsureEncryptionKey(t *testing.T) { assert.Equal(t, "dev-key", cfg.EncryptionKey) }) - t.Run("does not set key in production when not agent", func(t *testing.T) { + t.Run("does not set key in production non-agent mode", func(t *testing.T) { cfg := &RuntimeConfig{ AgentMode: false, Environment: "production", - EncryptionKey: "", + EncryptionKey: "existing", } + called := false ensureKeyFunc := func(ctx context.Context) (string, error) { - return "should-not-set", nil + called = true + return "new-key", nil } EnsureEncryptionKey(ctx, cfg, ensureKeyFunc) - assert.Empty(t, cfg.EncryptionKey) + assert.False(t, called) + assert.Equal(t, "existing", cfg.EncryptionKey) }) t.Run("handles error gracefully", func(t *testing.T) { cfg := &RuntimeConfig{ AgentMode: true, - EncryptionKey: "", + EncryptionKey: "fallback", } ensureKeyFunc := func(ctx context.Context) (string, error) { return "", errors.New("key generation failed") @@ -164,7 +157,7 @@ func TestEnsureEncryptionKey(t *testing.T) { EnsureEncryptionKey(ctx, cfg, ensureKeyFunc) - assert.Empty(t, cfg.EncryptionKey) + assert.Equal(t, "fallback", cfg.EncryptionKey) }) } @@ -266,7 +259,7 @@ func TestInitializeNonAgentFeatures(t *testing.T) { return nil } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, nil, nil, nil) + InitializeNonAgentFeatures(ctx, cfg, nil, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, nil) assert.False(t, createAdminCalled) assert.False(t, reconcileDefaultAdminAPIKeyCalled) @@ -276,8 +269,6 @@ func TestInitializeNonAgentFeatures(t *testing.T) { cfg := &RuntimeConfig{AgentMode: false} createAdminCalled := false reconcileDefaultAdminAPIKeyCalled := false - migrateOidcCalled := false - migrateDiscordCalled := false autoLoginInitCalled := false createAdminFunc := func(ctx context.Context) error { @@ -292,21 +283,11 @@ func TestInitializeNonAgentFeatures(t *testing.T) { autoLoginInitCalled = true return nil } - migrateOidcFunc := func(ctx context.Context) error { - migrateOidcCalled = true - return nil - } - migrateDiscordFunc := func(ctx context.Context) error { - migrateDiscordCalled = true - return nil - } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc, migrateOidcFunc, migrateDiscordFunc) + InitializeNonAgentFeatures(ctx, cfg, nil, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) assert.True(t, createAdminCalled) assert.True(t, reconcileDefaultAdminAPIKeyCalled) - assert.True(t, migrateOidcCalled) - assert.True(t, migrateDiscordCalled) assert.True(t, autoLoginInitCalled) }) @@ -321,429 +302,7 @@ func TestInitializeNonAgentFeatures(t *testing.T) { createAdminFunc := func(ctx context.Context) error { return errors.New("admin creation failed") } - migrateOidcFunc := func(ctx context.Context) error { - return errors.New("oidc migration failed") - } - migrateDiscordFunc := func(ctx context.Context) error { - return errors.New("discord migration failed") - } - InitializeNonAgentFeatures(ctx, cfg, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc, migrateOidcFunc, migrateDiscordFunc) + InitializeNonAgentFeatures(ctx, cfg, nil, createAdminFunc, reconcileDefaultAdminAPIKeyFunc, autoLoginInitFunc) }) } - -func TestMigrateSchedulerCronValues(t *testing.T) { - ctx := context.Background() - - t.Run("migrates minute-based intervals to cron", func(t *testing.T) { - settings := map[string]string{ - "pollingInterval": "15", - } - updateCalled := false - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - updateCalled = true - assert.Equal(t, "pollingInterval", key) - assert.Equal(t, "0 */15 * * * *", value) - return nil - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - - assert.True(t, updateCalled) - }) - - t.Run("migrates duration strings", func(t *testing.T) { - settings := map[string]string{ - "autoUpdateInterval": "30m", - } - updateCalled := false - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - updateCalled = true - assert.Equal(t, "autoUpdateInterval", key) - assert.Equal(t, "0 */30 * * * *", value) - return nil - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - - assert.True(t, updateCalled) - }) - - t.Run("skips already valid cron expressions", func(t *testing.T) { - settings := map[string]string{ - "pollingInterval": "0 */15 * * * *", - } - updateCalled := false - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - updateCalled = true - return nil - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - - assert.False(t, updateCalled) - }) - - t.Run("handles update errors", func(t *testing.T) { - settings := map[string]string{ - "pollingInterval": "15", - } - - getSettingFunc := func(ctx context.Context, key string, def string) string { - if val, ok := settings[key]; ok { - return val - } - return def - } - updateSettingFunc := func(ctx context.Context, key string, value string) error { - return errors.New("update failed") - } - reloadFunc := func(ctx context.Context) error { - return nil - } - - MigrateSchedulerCronValues(ctx, getSettingFunc, updateSettingFunc, reloadFunc) - }) -} - -func TestMigrateGitOpsSyncIntervals(t *testing.T) { - ctx := context.Background() - - t.Run("migrates intervals to minutes", func(t *testing.T) { - items := []IntervalMigrationItem{ - {ID: "sync-1", RawValue: "30"}, - {ID: "sync-2", RawValue: "1h"}, - } - updates := make(map[string]int) - - listFunc := func(ctx context.Context) ([]IntervalMigrationItem, error) { - return items, nil - } - updateFunc := func(ctx context.Context, id string, minutes int) error { - updates[id] = minutes - return nil - } - - MigrateGitOpsSyncIntervals(ctx, listFunc, updateFunc) - - assert.Len(t, updates, 1) - assert.Equal(t, 60, updates["sync-2"]) - }) - - t.Run("handles list error", func(t *testing.T) { - listFunc := func(ctx context.Context) ([]IntervalMigrationItem, error) { - return nil, errors.New("list failed") - } - updateFunc := func(ctx context.Context, id string, minutes int) error { - return nil - } - - MigrateGitOpsSyncIntervals(ctx, listFunc, updateFunc) - }) - - t.Run("handles nil functions", func(t *testing.T) { - MigrateGitOpsSyncIntervals(ctx, nil, nil) - }) -} - -func TestParseSchedulerDuration(t *testing.T) { - tests := []struct { - name string - raw string - defaultUnit time.Duration - wantDuration time.Duration - wantOk bool - }{ - {"empty string", "", time.Minute, 0, false}, - {"valid duration string", "30m", time.Minute, 30 * time.Minute, true}, - {"integer with default minute", "15", time.Minute, 15 * time.Minute, true}, - {"integer with default hour", "2", time.Hour, 2 * time.Hour, true}, - {"seconds duration", "45s", time.Minute, 45 * time.Second, true}, - {"hours duration", "2h", time.Minute, 2 * time.Hour, true}, - {"invalid format", "abc", time.Minute, 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotDuration, gotOk := parseSchedulerDuration(tt.raw, tt.defaultUnit) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantDuration, gotDuration) - } - }) - } -} - -func TestDurationToCron(t *testing.T) { - tests := []struct { - name string - duration time.Duration - wantCron string - wantWarn string - }{ - {"zero duration", 0, "", ""}, - {"negative duration", -1 * time.Minute, "", ""}, - {"30 seconds", 30 * time.Second, "*/30 * * * * *", ""}, - {"1 minute", time.Minute, "0 */1 * * * *", ""}, - {"15 minutes", 15 * time.Minute, "0 */15 * * * *", ""}, - {"1 hour", time.Hour, "0 0 */1 * * *", ""}, - {"24 hours", 24 * time.Hour, "0 0 0 */1 * *", ""}, - {"45 seconds rounds up", 45 * time.Second, "*/45 * * * * *", ""}, - {"90 seconds rounds to 2 min", 90 * time.Second, "0 */2 * * * *", "scheduler interval rounded up to minutes"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCron, gotWarn := durationToCron(tt.duration) - assert.Equal(t, tt.wantCron, gotCron) - if tt.wantWarn != "" { - assert.Contains(t, gotWarn, tt.wantWarn) - } - }) - } -} - -func TestMinutesToCron(t *testing.T) { - tests := []struct { - name string - minutes int - wantCron string - }{ - {"zero minutes", 0, ""}, - {"negative minutes", -1, ""}, - {"15 minutes", 15, "0 */15 * * * *"}, - {"30 minutes", 30, "0 */30 * * * *"}, - {"60 minutes", 60, "0 0 */1 * * *"}, - {"120 minutes", 120, "0 0 */2 * * *"}, - {"1440 minutes (1 day)", 1440, "0 0 0 */1 * *"}, - {"2880 minutes (2 days)", 2880, "0 0 0 */2 * *"}, - {"90 minutes", 90, "0 */90 * * * *"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCron := minutesToCron(tt.minutes) - assert.Equal(t, tt.wantCron, gotCron) - }) - } -} - -func TestNormalizeSchedulerValueToCron(t *testing.T) { - tests := []struct { - name string - raw string - defaultUnit time.Duration - wantCron string - wantShouldUpdate bool - }{ - {"empty string", "", time.Minute, "", false}, - {"@hourly special", "@hourly", time.Minute, "@hourly", false}, - {"valid 6-field cron", "0 */15 * * * *", time.Minute, "0 */15 * * * *", false}, - {"5-field cron adds second", "*/15 * * * *", time.Minute, "0 */15 * * * *", true}, - {"integer minutes", "30", time.Minute, "0 */30 * * * *", true}, - {"duration string", "1h", time.Minute, "0 0 */1 * * *", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCron, gotShouldUpdate, _ := normalizeSchedulerValueToCron(tt.raw, tt.defaultUnit) - assert.Equal(t, tt.wantCron, gotCron) - assert.Equal(t, tt.wantShouldUpdate, gotShouldUpdate) - }) - } -} - -func TestNormalizeSchedulerValueToMinutes(t *testing.T) { - tests := []struct { - name string - raw string - defaultUnit time.Duration - wantMinutes int - wantShouldUpdate bool - }{ - {"empty string", "", time.Minute, 0, false}, - {"@hourly special", "@hourly", time.Minute, 0, false}, - {"integer already minutes", "30", time.Minute, 30, false}, - {"duration string", "1h", time.Minute, 60, true}, - {"cron every hour", "0 0 * * * *", time.Minute, 60, true}, - {"cron every 15 min", "0 */15 * * * *", time.Minute, 15, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotShouldUpdate, _ := normalizeSchedulerValueToMinutes(tt.raw, tt.defaultUnit) - assert.Equal(t, tt.wantMinutes, gotMinutes) - assert.Equal(t, tt.wantShouldUpdate, gotShouldUpdate) - }) - } -} - -func TestCronToMinutes(t *testing.T) { - tests := []struct { - name string - fields []string - wantMinutes int - wantOk bool - }{ - {"every 30 seconds", []string{"*/30", "*", "*", "*", "*", "*"}, 1, true}, - {"every 15 minutes", []string{"0", "*/15", "*", "*", "*", "*"}, 15, true}, - {"every hour", []string{"0", "0", "*", "*", "*", "*"}, 60, true}, - {"every 2 hours", []string{"0", "0", "*/2", "*", "*", "*"}, 120, true}, - {"every day", []string{"0", "0", "0", "*/1", "*", "*"}, 1440, true}, - {"too few fields", []string{"0", "*/15", "*", "*", "*"}, 0, false}, - {"complex pattern", []string{"0", "15", "*/2", "*", "*", "*"}, 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := cronToMinutes(tt.fields) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} - -func TestParseCronStep(t *testing.T) { - tests := []struct { - name string - field string - wantStep int - wantOk bool - }{ - {"valid step", "*/15", 15, true}, - {"no prefix", "15", 0, false}, - {"zero step", "*/0", 0, false}, - {"negative step", "*/-5", 0, false}, - {"invalid number", "*/abc", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotStep, gotOk := parseCronStep(tt.field) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantStep, gotStep) - } - }) - } -} - -func TestTryConvertSecondStep(t *testing.T) { - tests := []struct { - name string - sec string - min string - hour string - day string - month string - weekday string - wantMinutes int - wantOk bool - }{ - {"every 30 seconds", "*/30", "*", "*", "*", "*", "*", 1, true}, - {"every 60 seconds", "*/60", "*", "*", "*", "*", "*", 1, true}, - {"every 90 seconds", "*/90", "*", "*", "*", "*", "*", 2, true}, - {"not wildcard minutes", "*/30", "0", "*", "*", "*", "*", 0, false}, - {"not step pattern", "0", "*", "*", "*", "*", "*", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := tryConvertSecondStep(tt.sec, tt.min, tt.hour, tt.day, tt.month, tt.weekday) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} - -func TestTryConvertMinuteOrHour(t *testing.T) { - tests := []struct { - name string - sec string - min string - hour string - day string - month string - weekday string - wantMinutes int - wantOk bool - }{ - {"every 15 minutes", "0", "*/15", "*", "*", "*", "*", 15, true}, - {"every hour", "0", "0", "*", "*", "*", "*", 60, true}, - {"every 2 hours", "0", "0", "*/2", "*", "*", "*", 120, true}, - {"complex pattern", "0", "30", "*/2", "*", "*", "*", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := tryConvertMinuteOrHour(tt.sec, tt.min, tt.hour, tt.day, tt.month, tt.weekday) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} - -func TestTryConvertDayStep(t *testing.T) { - tests := []struct { - name string - sec string - min string - hour string - day string - month string - weekday string - wantMinutes int - wantOk bool - }{ - {"every day", "0", "0", "0", "*/1", "*", "*", 1440, true}, - {"every 2 days", "0", "0", "0", "*/2", "*", "*", 2880, true}, - {"not midnight", "0", "0", "1", "*/1", "*", "*", 0, false}, - {"not step pattern", "0", "0", "0", "1", "*", "*", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMinutes, gotOk, _ := tryConvertDayStep(tt.sec, tt.min, tt.hour, tt.day, tt.month, tt.weekday) - assert.Equal(t, tt.wantOk, gotOk) - if gotOk { - assert.Equal(t, tt.wantMinutes, gotMinutes) - } - }) - } -} diff --git a/backend/pkg/libarcane/swarm/stack_deploy_engine.go b/backend/pkg/libarcane/swarm/stack_deploy_engine.go index ad8dfc588f..0e480b809f 100644 --- a/backend/pkg/libarcane/swarm/stack_deploy_engine.go +++ b/backend/pkg/libarcane/swarm/stack_deploy_engine.go @@ -188,10 +188,7 @@ func reconcileStackServices( if service.Name == "" { service.Name = key } - spec, err := buildServiceSpec(service, stackName, stackLabels, networkNameByKey, configMetaByKey, secretMetaByKey, project.Volumes) - if err != nil { - return nil, err - } + spec := buildServiceSpec(service, stackName, stackLabels, networkNameByKey, configMetaByKey, secretMetaByKey, project.Volumes) desiredServices[spec.Name] = struct{}{} if existing, ok := existingServices[spec.Name]; ok { @@ -485,69 +482,95 @@ func ensureSwarmSecrets(ctx context.Context, dockerClient *dockerclient.Client, } func ensureConfig(ctx context.Context, dockerClient *dockerclient.Client, name string, cfg composegotypes.ConfigObjConfig, stackLabels map[string]string, workingDir string) (resourceMeta, error) { - data, err := resolveFileObjectContent(composegotypes.FileObjectConfig(cfg), workingDir) - if err != nil { - return resourceMeta{}, fmt.Errorf("failed to load config %s: %w", name, err) - } + return ensureManagedFileResourceInternal(ctx, name, composegotypes.FileObjectConfig(cfg), cfg.Labels, stackLabels, workingDir, managedFileResourceOptionsInternal{ + ResourceType: "config", + Inspect: func(ctx context.Context, managedName string) (resourceMeta, error) { + return inspectConfig(ctx, dockerClient, managedName) + }, + Create: createConfigResourceInternal(dockerClient), + }) +} - hash := hashManagedResource(data) - managedName := managedResourceName(name, hash) - if meta, err := inspectConfig(ctx, dockerClient, managedName); err == nil { - return meta, nil - } else if !cerrdefs.IsNotFound(err) { - return resourceMeta{}, fmt.Errorf("failed to inspect config %s: %w", managedName, err) +func ensureSecret(ctx context.Context, dockerClient *dockerclient.Client, name string, cfg composegotypes.SecretConfig, stackLabels map[string]string, workingDir string) (resourceMeta, error) { + return ensureManagedFileResourceInternal(ctx, name, composegotypes.FileObjectConfig(cfg), cfg.Labels, stackLabels, workingDir, managedFileResourceOptionsInternal{ + ResourceType: "secret", + Inspect: func(ctx context.Context, managedName string) (resourceMeta, error) { + return inspectSecret(ctx, dockerClient, managedName) + }, + Create: createSecretResourceInternal(dockerClient), + }) +} + +func createConfigResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string, map[string]string, []byte) (resourceMeta, error) { + return func(ctx context.Context, managedName string, labels map[string]string, data []byte) (resourceMeta, error) { + spec := swarm.ConfigSpec{ + Annotations: managedResourceAnnotationsInternal(managedName, labels), + Data: data, + } + + resp, err := dockerClient.ConfigCreate(ctx, dockerclient.ConfigCreateOptions{Spec: spec}) + if err != nil { + return resourceMeta{}, fmt.Errorf("failed to create config %s: %w", managedName, err) + } + return resourceMeta{ID: resp.ID, Name: managedName}, nil } +} - labels := mergeLabels(cfg.Labels, stackLabels) - labels[resourceTypeLabel] = "config" - labels[resourceNameLabel] = name - labels[resourceHashLabel] = hash - spec := swarm.ConfigSpec{ - Annotations: swarm.Annotations{ - Name: managedName, - Labels: labels, - }, - Data: data, +func createSecretResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string, map[string]string, []byte) (resourceMeta, error) { + return func(ctx context.Context, managedName string, labels map[string]string, data []byte) (resourceMeta, error) { + spec := swarm.SecretSpec{ + Annotations: managedResourceAnnotationsInternal(managedName, labels), + Data: data, + } + + resp, err := dockerClient.SecretCreate(ctx, dockerclient.SecretCreateOptions{Spec: spec}) + if err != nil { + return resourceMeta{}, fmt.Errorf("failed to create secret %s: %w", managedName, err) + } + return resourceMeta{ID: resp.ID, Name: managedName}, nil } +} - resp, err := dockerClient.ConfigCreate(ctx, dockerclient.ConfigCreateOptions{Spec: spec}) - if err != nil { - return resourceMeta{}, fmt.Errorf("failed to create config %s: %w", managedName, err) +func managedResourceAnnotationsInternal(name string, labels map[string]string) swarm.Annotations { + return swarm.Annotations{ + Name: name, + Labels: labels, } - return resourceMeta{ID: resp.ID, Name: managedName}, nil } -func ensureSecret(ctx context.Context, dockerClient *dockerclient.Client, name string, cfg composegotypes.SecretConfig, stackLabels map[string]string, workingDir string) (resourceMeta, error) { - data, err := resolveFileObjectContent(composegotypes.FileObjectConfig(cfg), workingDir) +type managedFileResourceOptionsInternal struct { + ResourceType string + Inspect func(context.Context, string) (resourceMeta, error) + Create func(context.Context, string, map[string]string, []byte) (resourceMeta, error) +} + +func ensureManagedFileResourceInternal( + ctx context.Context, + name string, + fileObject composegotypes.FileObjectConfig, + labels map[string]string, + stackLabels map[string]string, + workingDir string, + opts managedFileResourceOptionsInternal, +) (resourceMeta, error) { + data, err := resolveFileObjectContent(fileObject, workingDir) if err != nil { - return resourceMeta{}, fmt.Errorf("failed to load secret %s: %w", name, err) + return resourceMeta{}, fmt.Errorf("failed to load %s %s: %w", opts.ResourceType, name, err) } hash := hashManagedResource(data) managedName := managedResourceName(name, hash) - if meta, err := inspectSecret(ctx, dockerClient, managedName); err == nil { + if meta, err := opts.Inspect(ctx, managedName); err == nil { return meta, nil } else if !cerrdefs.IsNotFound(err) { - return resourceMeta{}, fmt.Errorf("failed to inspect secret %s: %w", managedName, err) + return resourceMeta{}, fmt.Errorf("failed to inspect %s %s: %w", opts.ResourceType, managedName, err) } - labels := mergeLabels(cfg.Labels, stackLabels) - labels[resourceTypeLabel] = "secret" - labels[resourceNameLabel] = name - labels[resourceHashLabel] = hash - spec := swarm.SecretSpec{ - Annotations: swarm.Annotations{ - Name: managedName, - Labels: labels, - }, - Data: data, - } - - resp, err := dockerClient.SecretCreate(ctx, dockerclient.SecretCreateOptions{Spec: spec}) - if err != nil { - return resourceMeta{}, fmt.Errorf("failed to create secret %s: %w", managedName, err) - } - return resourceMeta{ID: resp.ID, Name: managedName}, nil + resourceLabels := mergeLabels(labels, stackLabels) + resourceLabels[resourceTypeLabel] = opts.ResourceType + resourceLabels[resourceNameLabel] = name + resourceLabels[resourceHashLabel] = hash + return opts.Create(ctx, managedName, resourceLabels, data) } func inspectConfig(ctx context.Context, dockerClient *dockerclient.Client, name string) (resourceMeta, error) { @@ -587,7 +610,7 @@ func buildServiceSpec( configMetaByKey map[string]resourceMeta, secretMetaByKey map[string]resourceMeta, projectVolumes composegotypes.Volumes, -) (swarm.ServiceSpec, error) { +) swarm.ServiceSpec { serviceName := stackScopedName(stackName, service.Name) serviceLabels := mergeLabels(nil, stackLabels) if service.Deploy != nil { @@ -642,7 +665,7 @@ func buildServiceSpec( applyDeployConfig(&spec, service.Deploy, service.Scale) applyServicePorts(&spec, service.Ports) - return spec, nil + return spec } func createSwarmService( @@ -1056,8 +1079,8 @@ func convertIPAM(cfg composegotypes.IPAMConfig) *network.IPAM { if pool == nil { continue } - subnet, _ := parsePrefix(pool.Subnet) - ipRange, _ := parsePrefix(pool.IPRange) + subnet := parsePrefix(pool.Subnet) + ipRange := parsePrefix(pool.IPRange) gateway, _ := parseAddr(pool.Gateway) pools = append(pools, network.IPAMConfig{ Subnet: subnet, @@ -1097,16 +1120,16 @@ func parseAddr(value string) (netip.Addr, bool) { return addr, true } -func parsePrefix(value string) (netip.Prefix, bool) { +func parsePrefix(value string) netip.Prefix { value = strings.TrimSpace(value) if value == "" { - return netip.Prefix{}, false + return netip.Prefix{} } prefix, err := netip.ParsePrefix(value) if err != nil { - return netip.Prefix{}, false + return netip.Prefix{} } - return prefix, true + return prefix } func parseAuxAddresses(values map[string]string) map[string]netip.Addr { @@ -1340,49 +1363,99 @@ func resolveRegistryAuthForSpec( } func cleanupStaleConfigs(ctx context.Context, dockerClient *dockerclient.Client, stackName string, desiredNames map[string]struct{}) error { - filters := make(dockerclient.Filters) - filters.Add("label", fmt.Sprintf("%s=%s", swarmtypes.StackNamespaceLabel, stackName)) - filters.Add("label", resourceTypeLabel+"=config") + return cleanupStaleManagedResourcesInternal(ctx, stackName, desiredNames, staleManagedResourceOptionsInternal{ + ResourceType: "config", + List: listStaleConfigResourcesInternal(dockerClient), + Remove: removeConfigResourceInternal(dockerClient), + }) +} - configsResult, err := dockerClient.ConfigList(ctx, dockerclient.ConfigListOptions{Filters: filters}) - if err != nil { - return fmt.Errorf("failed to list stack configs: %w", err) - } +func cleanupStaleSecrets(ctx context.Context, dockerClient *dockerclient.Client, stackName string, desiredNames map[string]struct{}) error { + return cleanupStaleManagedResourcesInternal(ctx, stackName, desiredNames, staleManagedResourceOptionsInternal{ + ResourceType: "secret", + List: listStaleSecretResourcesInternal(dockerClient), + Remove: removeSecretResourceInternal(dockerClient), + }) +} - for _, cfg := range configsResult.Items { - if _, ok := desiredNames[cfg.Spec.Name]; ok { - continue +func listStaleConfigResourcesInternal(dockerClient *dockerclient.Client) func(context.Context, dockerclient.Filters) ([]staleManagedResourceInternal, error) { + return func(ctx context.Context, filters dockerclient.Filters) ([]staleManagedResourceInternal, error) { + configsResult, err := dockerClient.ConfigList(ctx, dockerclient.ConfigListOptions{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("failed to list stack configs: %w", err) } - if _, err := dockerClient.ConfigRemove(ctx, cfg.ID, dockerclient.ConfigRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { - if isStaleSwarmResourceStillInUse(err) { - continue - } - return fmt.Errorf("failed to remove stale stack config %s: %w", cfg.Spec.Name, err) + + return collectStaleManagedResourcesInternal(configsResult.Items, func(cfg swarm.Config) staleManagedResourceInternal { + return staleManagedResourceInternal{ID: cfg.ID, Name: cfg.Spec.Name} + }), nil + } +} + +func listStaleSecretResourcesInternal(dockerClient *dockerclient.Client) func(context.Context, dockerclient.Filters) ([]staleManagedResourceInternal, error) { + return func(ctx context.Context, filters dockerclient.Filters) ([]staleManagedResourceInternal, error) { + secretsResult, err := dockerClient.SecretList(ctx, dockerclient.SecretListOptions{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("failed to list stack secrets: %w", err) } + + return collectStaleManagedResourcesInternal(secretsResult.Items, func(secret swarm.Secret) staleManagedResourceInternal { + return staleManagedResourceInternal{ID: secret.ID, Name: secret.Spec.Name} + }), nil } +} - return nil +func collectStaleManagedResourcesInternal[T any](items []T, convert func(T) staleManagedResourceInternal) []staleManagedResourceInternal { + resources := make([]staleManagedResourceInternal, 0, len(items)) + for _, item := range items { + resources = append(resources, convert(item)) + } + return resources } -func cleanupStaleSecrets(ctx context.Context, dockerClient *dockerclient.Client, stackName string, desiredNames map[string]struct{}) error { +func removeConfigResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string) error { + return func(ctx context.Context, id string) error { + _, err := dockerClient.ConfigRemove(ctx, id, dockerclient.ConfigRemoveOptions{}) + return err + } +} + +func removeSecretResourceInternal(dockerClient *dockerclient.Client) func(context.Context, string) error { + return func(ctx context.Context, id string) error { + _, err := dockerClient.SecretRemove(ctx, id, dockerclient.SecretRemoveOptions{}) + return err + } +} + +type staleManagedResourceInternal struct { + ID string + Name string +} + +type staleManagedResourceOptionsInternal struct { + ResourceType string + List func(context.Context, dockerclient.Filters) ([]staleManagedResourceInternal, error) + Remove func(context.Context, string) error +} + +func cleanupStaleManagedResourcesInternal(ctx context.Context, stackName string, desiredNames map[string]struct{}, opts staleManagedResourceOptionsInternal) error { filters := make(dockerclient.Filters) filters.Add("label", fmt.Sprintf("%s=%s", swarmtypes.StackNamespaceLabel, stackName)) - filters.Add("label", resourceTypeLabel+"=secret") + filters.Add("label", resourceTypeLabel+"="+opts.ResourceType) - secretsResult, err := dockerClient.SecretList(ctx, dockerclient.SecretListOptions{Filters: filters}) + resources, err := opts.List(ctx, filters) if err != nil { - return fmt.Errorf("failed to list stack secrets: %w", err) + return err } - for _, secret := range secretsResult.Items { - if _, ok := desiredNames[secret.Spec.Name]; ok { + for _, resource := range resources { + if _, ok := desiredNames[resource.Name]; ok { continue } - if _, err := dockerClient.SecretRemove(ctx, secret.ID, dockerclient.SecretRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { + if err := opts.Remove(ctx, resource.ID); err != nil && !cerrdefs.IsNotFound(err) { if isStaleSwarmResourceStillInUse(err) { continue } - return fmt.Errorf("failed to remove stale stack secret %s: %w", secret.Spec.Name, err) + return fmt.Errorf("failed to remove stale stack %s %s: %w", opts.ResourceType, resource.Name, err) } } diff --git a/backend/pkg/libarcane/system/gpu.go b/backend/pkg/libarcane/system/gpu.go index a785391b08..29889fcb63 100644 --- a/backend/pkg/libarcane/system/gpu.go +++ b/backend/pkg/libarcane/system/gpu.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/csv" + "errors" "fmt" "log/slog" "os" @@ -81,7 +82,7 @@ func (m *GPUMonitor) Stats(ctx context.Context) ([]systemtypes.GPUStats, error) t := m.gpuType m.cacheMu.RUnlock() if t == "" { - return nil, fmt.Errorf("no supported GPU found") + return nil, errors.New("no supported GPU found") } return m.statsForTypeInternal(ctx, t) } @@ -95,7 +96,7 @@ func (m *GPUMonitor) statsForTypeInternal(ctx context.Context, gpuType string) ( case "intel": return getIntelStatsInternal(ctx) default: - return nil, fmt.Errorf("no supported GPU found") + return nil, errors.New("no supported GPU found") } } @@ -124,21 +125,21 @@ func (m *GPUMonitor) detectInternal(ctx context.Context) error { slog.InfoContext(ctx, "Using configured GPU type", "type", "nvidia") return nil } - return fmt.Errorf("nvidia-smi not found but GPU_TYPE set to nvidia") + return errors.New("nvidia-smi not found but GPU_TYPE set to nvidia") case "amd": if HasAMDGPU() { m.markDetectedInternal("amd", AMDGPUSysfsPath) slog.InfoContext(ctx, "Using configured GPU type", "type", "amd") return nil } - return fmt.Errorf("AMD GPU not found in sysfs but GPU_TYPE set to amd") + return errors.New("AMD GPU not found in sysfs but GPU_TYPE set to amd") case "intel": if path, err := exec.LookPath("intel_gpu_top"); err == nil { m.markDetectedInternal("intel", path) slog.InfoContext(ctx, "Using configured GPU type", "type", "intel") return nil } - return fmt.Errorf("intel_gpu_top not found but GPU_TYPE set to intel") + return errors.New("intel_gpu_top not found but GPU_TYPE set to intel") default: slog.WarnContext(ctx, "Invalid GPU_TYPE specified, falling back to auto-detection", "gpu_type", t) } @@ -161,7 +162,7 @@ func (m *GPUMonitor) detectInternal(ctx context.Context) error { } m.detectionDone = true - return fmt.Errorf("no supported GPU found") + return errors.New("no supported GPU found") } // HasAMDGPU reports whether a card with mem_info_vram_total exists under AMDGPUSysfsPath. @@ -242,7 +243,7 @@ func getNvidiaStatsInternal(ctx context.Context) ([]systemtypes.GPUStats, error) } if len(stats) == 0 { - return nil, fmt.Errorf("no GPU data parsed from nvidia-smi") + return nil, errors.New("no GPU data parsed from nvidia-smi") } slog.DebugContext(ctx, "Collected NVIDIA GPU stats", "gpu_count", len(stats)) @@ -265,11 +266,11 @@ func getAMDStatsInternal(ctx context.Context) ([]systemtypes.GPUStats, error) { } devicePath := fmt.Sprintf("%s/%s/device", AMDGPUSysfsPath, name) - memTotalBytes, err := readSysfsValueInternal(fmt.Sprintf("%s/mem_info_vram_total", devicePath)) + memTotalBytes, err := readSysfsValueInternal(devicePath + "/mem_info_vram_total") if err != nil { continue } - memUsedBytes, err := readSysfsValueInternal(fmt.Sprintf("%s/mem_info_vram_used", devicePath)) + memUsedBytes, err := readSysfsValueInternal(devicePath + "/mem_info_vram_used") if err != nil { slog.WarnContext(ctx, "Failed to read AMD GPU memory used", "card", name, "error", err) continue @@ -285,7 +286,7 @@ func getAMDStatsInternal(ctx context.Context) ([]systemtypes.GPUStats, error) { } if len(stats) == 0 { - return nil, fmt.Errorf("no AMD GPU data found in sysfs") + return nil, errors.New("no AMD GPU data found in sysfs") } slog.DebugContext(ctx, "Collected AMD GPU stats", "gpu_count", len(stats)) diff --git a/backend/pkg/libarcane/timeouts/timeouts.go b/backend/pkg/libarcane/timeouts/timeouts.go index 7c9aeaee30..1fc74bbef6 100644 --- a/backend/pkg/libarcane/timeouts/timeouts.go +++ b/backend/pkg/libarcane/timeouts/timeouts.go @@ -23,6 +23,6 @@ func GetDuration(settingSeconds int, defaultDuration time.Duration) time.Duratio return defaultDuration } -func WithTimeout(ctx context.Context, settingSeconds int, defaultDuration time.Duration) (context.Context, context.CancelFunc) { //nolint:gosec // helper intentionally returns the cancel func to callers. - return context.WithTimeout(ctx, GetDuration(settingSeconds, defaultDuration)) //nolint:gosec // helper intentionally returns the cancel func to callers. +func WithTimeout(ctx context.Context, settingSeconds int, defaultDuration time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, GetDuration(settingSeconds, defaultDuration)) } diff --git a/backend/pkg/libarcane/ws/diagnostics.go b/backend/pkg/libarcane/ws/diagnostics.go index 36b717f568..58b5369791 100644 --- a/backend/pkg/libarcane/ws/diagnostics.go +++ b/backend/pkg/libarcane/ws/diagnostics.go @@ -13,6 +13,7 @@ const workerGoroutineCountTTL = 5 * time.Second var workerGoroutineCountCache struct { sync.Mutex + value int at time.Time } diff --git a/backend/pkg/pagination/db_utils.go b/backend/pkg/pagination/db_utils.go index 253474fd76..a8e3e58bc7 100644 --- a/backend/pkg/pagination/db_utils.go +++ b/backend/pkg/pagination/db_utils.go @@ -1,8 +1,10 @@ package pagination import ( + "fmt" "strings" + "github.com/getarcaneapp/arcane/backend/pkg/utils/mapper" "gorm.io/gorm" ) @@ -23,6 +25,38 @@ func ApplyFilter(q *gorm.DB, column string, value string) *gorm.DB { return q.Where(column+" = ?", value) } +// ApplyLikeSearch adds a LIKE search condition using the same wildcard pattern +// for every placeholder in condition. +func ApplyLikeSearch(q *gorm.DB, search string, condition string) *gorm.DB { + term := strings.TrimSpace(search) + if term == "" { + return q + } + + pattern := "%" + term + "%" + args := make([]any, strings.Count(condition, "?")) + for i := range args { + args[i] = pattern + } + + return q.Where(condition, args...) +} + +// PaginateSortAndMapDB paginates DB records and maps them to API DTOs. +func PaginateSortAndMapDB[M any, D any](params QueryParams, query *gorm.DB, records *[]M) ([]D, Response, error) { + paginationResp, err := PaginateAndSortDB(params, query, records) + if err != nil { + return nil, Response{}, fmt.Errorf("paginate db records: %w", err) + } + + out, err := mapper.MapSlice[M, D](*records) + if err != nil { + return nil, Response{}, fmt.Errorf("map db records: %w", err) + } + + return out, paginationResp, nil +} + // ApplyBooleanFilter adds a WHERE clause for boolean columns. // It detects comma-separated values and maps "true"/"1" to true and "false"/"0" to false. func ApplyBooleanFilter(q *gorm.DB, column string, value string) *gorm.DB { diff --git a/backend/pkg/pagination/filters.go b/backend/pkg/pagination/filters.go index 399667bf6f..11379a5857 100644 --- a/backend/pkg/pagination/filters.go +++ b/backend/pkg/pagination/filters.go @@ -27,7 +27,7 @@ func SearchOrderAndPaginate[T any](items []T, params QueryParams, searchConfig C items = sortFunction(items, params.SortParams, searchConfig.SortBindings) totalCount := len(items) - items = paginateItemsFunction(items, params.PaginationParams) + items = paginateItemsFunction(items, params.Params) return FilterResult[T]{ Items: items, diff --git a/backend/pkg/pagination/pagination.go b/backend/pkg/pagination/pagination.go index b61e428deb..c96e087636 100644 --- a/backend/pkg/pagination/pagination.go +++ b/backend/pkg/pagination/pagination.go @@ -1,6 +1,6 @@ package pagination -type PaginationParams struct { +type Params struct { Start int Limit int // SkipCount opts out of the COUNT(*) query that backs TotalItems/TotalPages. @@ -13,7 +13,7 @@ type PaginationParams struct { // UnknownTotal is the sentinel returned in TotalItems/TotalPages when SkipCount is set. const UnknownTotal int64 = -1 -func paginateItemsFunction[T any](items []T, params PaginationParams) []T { +func paginateItemsFunction[T any](items []T, params Params) []T { if params.Limit <= 0 { return items } diff --git a/backend/pkg/pagination/pagination_test.go b/backend/pkg/pagination/pagination_test.go index 6325c9d2fe..cb41a3b826 100644 --- a/backend/pkg/pagination/pagination_test.go +++ b/backend/pkg/pagination/pagination_test.go @@ -21,7 +21,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 10, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: -1, }, @@ -42,7 +42,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 0, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: -1, }, @@ -63,7 +63,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 100, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: 10, }, @@ -84,7 +84,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 100, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 10, Limit: 10, }, @@ -105,7 +105,7 @@ func TestBuildResponseFromFilterResult(t *testing.T) { TotalAvailable: 3, }, params: QueryParams{ - PaginationParams: PaginationParams{ + Params: Params{ Start: 0, Limit: 0, }, @@ -133,32 +133,32 @@ func TestPaginateItemsFunction(t *testing.T) { tests := []struct { name string - params PaginationParams + params Params expected []int }{ { name: "show all mode (limit = -1)", - params: PaginationParams{Start: 0, Limit: -1}, + params: Params{Start: 0, Limit: -1}, expected: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, }, { name: "show all mode (limit = 0)", - params: PaginationParams{Start: 0, Limit: 0}, + params: Params{Start: 0, Limit: 0}, expected: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, }, { name: "normal pagination first page", - params: PaginationParams{Start: 0, Limit: 3}, + params: Params{Start: 0, Limit: 3}, expected: []int{1, 2, 3}, }, { name: "normal pagination second page", - params: PaginationParams{Start: 3, Limit: 3}, + params: Params{Start: 3, Limit: 3}, expected: []int{4, 5, 6}, }, { name: "pagination at end of list", - params: PaginationParams{Start: 8, Limit: 5}, + params: Params{Start: 8, Limit: 5}, expected: []int{9, 10}, }, } diff --git a/backend/pkg/pagination/params.go b/backend/pkg/pagination/params.go index d38099a381..c0e0aa66a9 100644 --- a/backend/pkg/pagination/params.go +++ b/backend/pkg/pagination/params.go @@ -3,6 +3,7 @@ package pagination type QueryParams struct { SearchQuery SortParams - PaginationParams + Params + Filters map[string]string } diff --git a/backend/pkg/pagination/search.go b/backend/pkg/pagination/search.go index 3b9ba0fa2c..d9a3df59a4 100644 --- a/backend/pkg/pagination/search.go +++ b/backend/pkg/pagination/search.go @@ -4,7 +4,8 @@ import ( "strings" ) -// Return any error to skip the field (for when matching an unknown state on an enum) +// SearchAccessor extracts a searchable string from T. Return any error to skip +// the field (e.g. when matching an unknown enum state). // // Note: returning ("", nil) will match! type SearchAccessor[T any] = func(T) (string, error) diff --git a/backend/pkg/pagination/skipcount_test.go b/backend/pkg/pagination/skipcount_test.go index 7f07b20c85..4aea66bb0a 100644 --- a/backend/pkg/pagination/skipcount_test.go +++ b/backend/pkg/pagination/skipcount_test.go @@ -30,8 +30,8 @@ func TestPaginateAndSortDB_SkipCountReturnsUnknownTotals(t *testing.T) { var got []widget resp, err := PaginateAndSortDB(QueryParams{ - PaginationParams: PaginationParams{Start: 0, Limit: 2, SkipCount: true}, - SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, + Params: Params{Start: 0, Limit: 2, SkipCount: true}, + SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, }, db.Model(&widget{}), &got) require.NoError(t, err) require.Len(t, got, 2) @@ -46,8 +46,8 @@ func TestPaginateAndSortDB_DefaultStillCounts(t *testing.T) { var got []widget resp, err := PaginateAndSortDB(QueryParams{ - PaginationParams: PaginationParams{Start: 0, Limit: 2}, - SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, + Params: Params{Start: 0, Limit: 2}, + SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, }, db.Model(&widget{}), &got) require.NoError(t, err) require.Len(t, got, 2) @@ -60,8 +60,8 @@ func TestPaginateAndSortDB_SkipCountShowAll(t *testing.T) { var got []widget resp, err := PaginateAndSortDB(QueryParams{ - PaginationParams: PaginationParams{Start: 0, Limit: -1, SkipCount: true}, - SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, + Params: Params{Start: 0, Limit: -1, SkipCount: true}, + SortParams: SortParams{Sort: "Name", Order: SortOrder("asc")}, }, db.Model(&widget{}), &got) require.NoError(t, err) require.Len(t, got, 5) diff --git a/backend/pkg/projects/cmds.go b/backend/pkg/projects/cmds.go index be825ee11d..52afa8ff2a 100644 --- a/backend/pkg/projects/cmds.go +++ b/backend/pkg/projects/cmds.go @@ -11,6 +11,8 @@ import ( "github.com/docker/compose/v5/pkg/api" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" + + "github.com/getarcaneapp/arcane/backend/pkg/utils" ) // ProgressWriterKey can be set on a context to enable JSON-line progress updates. @@ -45,6 +47,9 @@ const defaultComposeTimeout = 30 * time.Minute // timeouts and proxy deadline cancellations. A standalone timeout is applied // so the operation cannot run forever. See #1209. func detachFromHTTPContextInternal(parent context.Context) (context.Context, context.CancelFunc) { + if utils.IsAppLifecycleContext(parent) { + return context.WithTimeout(parent, defaultComposeTimeout) + } ctx := context.WithoutCancel(parent) return context.WithTimeout(ctx, defaultComposeTimeout) } @@ -58,7 +63,11 @@ func ComposeRestart(ctx context.Context, proj *types.Project, services []string) return err } defer func() { _ = c.Close() }() - return c.svc.Restart(restartCtx, proj.Name, api.RestartOptions{Services: services}) + + progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer) + return runWithContainerPolling(restartCtx, c.svc, proj.Name, progressWriter, func() error { + return c.svc.Restart(restartCtx, proj.Name, api.RestartOptions{Services: services}) + }) } func ComposePull(ctx context.Context, proj *types.Project, services []string) error { @@ -92,7 +101,6 @@ func ComposeStop(ctx context.Context, proj *types.Project, services []string) er if len(services) == 0 { return nil } - // Detach from the HTTP request context. See #1209. stopCtx, cancel := detachFromHTTPContextInternal(ctx) defer cancel() @@ -101,7 +109,11 @@ func ComposeStop(ctx context.Context, proj *types.Project, services []string) er return err } defer func() { _ = c.Close() }() - return c.svc.Stop(stopCtx, proj.Name, api.StopOptions{Services: services}) + + progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer) + return runWithContainerPolling(stopCtx, c.svc, proj.Name, progressWriter, func() error { + return c.svc.Stop(stopCtx, proj.Name, api.StopOptions{Services: services}) + }) } func ComposeUp(ctx context.Context, proj *types.Project, services []string, removeOrphans bool, forceRecreate bool) error { @@ -245,6 +257,69 @@ func deployPhaseFromSummary(cs api.ContainerSummary) string { } } +func runWithContainerPolling(ctx context.Context, svc api.Compose, projectName string, progressWriter io.Writer, operation func() error) error { + if progressWriter == nil { + return operation() + } + + pollCtx, cancel := context.WithCancel(ctx) + defer cancel() + + pollDone := make(chan struct{}) + go func() { + defer close(pollDone) + pollContainerStatus(pollCtx, svc, projectName, progressWriter) + }() + + err := operation() + cancel() + <-pollDone + return err +} + +func pollContainerStatus(ctx context.Context, svc api.Compose, projectName string, progressWriter io.Writer) { + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + lastState := map[string]string{} + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + containers, err := svc.Ps(ctx, projectName, api.PsOptions{All: true}) + if err != nil { + continue + } + for _, cs := range containers { + name := strings.TrimSpace(cs.Service) + if name == "" { + name = strings.TrimSpace(cs.Name) + } + if name == "" { + continue + } + + state := string(cs.State) + sig := state + "|" + string(cs.Health) + "|" + strings.TrimSpace(cs.Status) + if lastState[name] == sig { + continue + } + lastState[name] = sig + + writeJSONLine(progressWriter, map[string]any{ + "type": "container", + "service": name, + "state": state, + "health": string(cs.Health), + "status": strings.TrimSpace(cs.Status), + }) + } + } + } +} + func ComposePs(ctx context.Context, proj *types.Project, services []string, all bool) ([]api.ContainerSummary, error) { c, err := NewClient(ctx) if err != nil { @@ -265,7 +340,10 @@ func ComposeDown(ctx context.Context, proj *types.Project, removeVolumes bool) e } defer func() { _ = c.Close() }() - return c.svc.Down(downCtx, proj.Name, api.DownOptions{RemoveOrphans: true, Volumes: removeVolumes}) + progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer) + return runWithContainerPolling(downCtx, c.svc, proj.Name, progressWriter, func() error { + return c.svc.Down(downCtx, proj.Name, api.DownOptions{RemoveOrphans: true, Volumes: removeVolumes}) + }) } func ComposeLogs(ctx context.Context, projectName string, out io.Writer, follow bool, tail string) error { diff --git a/backend/pkg/projects/cmds_test.go b/backend/pkg/projects/cmds_test.go index c462e8af7f..c45ff6126f 100644 --- a/backend/pkg/projects/cmds_test.go +++ b/backend/pkg/projects/cmds_test.go @@ -6,6 +6,7 @@ import ( "time" composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/getarcaneapp/arcane/backend/pkg/utils" "github.com/stretchr/testify/require" ) @@ -59,6 +60,16 @@ func TestDetachFromHTTPContextInternal(t *testing.T) { require.True(t, ok) require.InDelta(t, float64(defaultComposeTimeout), float64(time.Until(deadline)), float64(5*time.Second)) }) + + t.Run("app lifecycle context cancels detached work on shutdown", func(t *testing.T) { + appCtx, cancelApp := context.WithCancel(utils.WithAppLifecycleContext(context.Background())) + detached, detachedCancel := detachFromHTTPContextInternal(appCtx) + defer detachedCancel() + + cancelApp() + + require.ErrorIs(t, detached.Err(), context.Canceled) + }) } func TestComposeStopSkipsWhenNoServicesSpecified(t *testing.T) { diff --git a/backend/pkg/projects/compose.go b/backend/pkg/projects/compose.go index 2c915d7f1e..13fc84bf98 100644 --- a/backend/pkg/projects/compose.go +++ b/backend/pkg/projects/compose.go @@ -41,6 +41,7 @@ func NewClient(ctx context.Context) (*Client, error) { type inspectCompatibleDockerCli struct { command.Cli + apiClient client.APIClient } diff --git a/backend/pkg/projects/env.go b/backend/pkg/projects/env.go index e1ba25e580..c39f441934 100644 --- a/backend/pkg/projects/env.go +++ b/backend/pkg/projects/env.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -137,7 +138,7 @@ func (l *EnvLoader) loadAndMergeGlobalEnv(ctx context.Context, path string, envM } func (l *EnvLoader) loadAndMergeProjectEnv(ctx context.Context, path string, envMap, injectionVars EnvMap) error { - key := strings.Join([]string{path, l.projectsDir, fmt.Sprint(l.autoInjectEnv), envContextFingerprintInternal(envMap)}, "\x00") + key := strings.Join([]string{path, l.projectsDir, strconv.FormatBool(l.autoInjectEnv), envContextFingerprintInternal(envMap)}, "\x00") entry, err := loadCachedEnvFileInternal(ctx, projectEnvFileCache, key, path, envMap) if err != nil { return err @@ -400,7 +401,7 @@ func parseEnvWithContext(r io.Reader, contextEnv EnvMap) (EnvMap, error) { return nil, fmt.Errorf("parse env: %w", err) } - return EnvMap(envMap), nil + return envMap, nil } func readOptionalProjectFileInternal(projectPath, fileName string) (string, bool, error) { diff --git a/backend/pkg/projects/fs_templates.go b/backend/pkg/projects/fs_templates.go index 20addb3698..a7fc6a4f05 100644 --- a/backend/pkg/projects/fs_templates.go +++ b/backend/pkg/projects/fs_templates.go @@ -31,13 +31,12 @@ func ReadFolderComposeTemplate(baseDir, folder string) (string, *string, string, for _, envName := range []string{".env.example", ".env"} { envPath := filepath.Join(folderPath, envName) if eb, rerr := os.ReadFile(envPath); rerr == nil { - s := string(eb) - envPtr = &s + envPtr = new(string(eb)) break } } - desc := fmt.Sprintf("Imported from %s", composePath) + desc := "Imported from " + composePath return string(b), envPtr, desc, true, nil } diff --git a/backend/pkg/projects/fs_util.go b/backend/pkg/projects/fs_util.go index 7408b7401e..5ce01be287 100644 --- a/backend/pkg/projects/fs_util.go +++ b/backend/pkg/projects/fs_util.go @@ -3,6 +3,7 @@ package projects import ( "bytes" "context" + "errors" "fmt" "log/slog" "os" @@ -458,7 +459,7 @@ func CreateUniqueDir(projectsRoot, basePath, name string, perm os.FileMode) (pat // Reject empty or invalid sanitized names if sanitized == "" || strings.Trim(sanitized, "_") == "" { - return "", "", fmt.Errorf("invalid project name: results in empty directory name") + return "", "", errors.New("invalid project name: results in empty directory name") } // Get absolute path of the true projects root for validation @@ -481,7 +482,7 @@ func CreateUniqueDir(projectsRoot, basePath, name string, perm os.FileMode) (pat // Security check: ensure candidate is a subdirectory of projectsRoot if !IsSafeSubdirectory(projectsRootAbs, candidateAbs) { - return "", "", fmt.Errorf("project directory would be outside allowed projects root") + return "", "", errors.New("project directory would be outside allowed projects root") } if mkErr := os.Mkdir(candidate, perm); mkErr == nil { @@ -493,7 +494,7 @@ func CreateUniqueDir(projectsRoot, basePath, name string, perm os.FileMode) (pat if strings.HasPrefix(candidateAbs, projectsRootAbs+string(filepath.Separator)) { _ = os.Remove(candidateAbs) } - return "", "", fmt.Errorf("created directory is outside allowed projects root") + return "", "", errors.New("created directory is outside allowed projects root") } return candidate, folderName, nil diff --git a/backend/pkg/projects/fs_writer.go b/backend/pkg/projects/fs_writer.go index 391c5914cb..6466c1c65a 100644 --- a/backend/pkg/projects/fs_writer.go +++ b/backend/pkg/projects/fs_writer.go @@ -55,7 +55,7 @@ func WriteComposeFile(projectsRoot, dirPath, content string) error { rootAbs = filepath.Clean(rootAbs) if !IsSafeSubdirectory(rootAbs, dirPath) { - return fmt.Errorf("refusing to write compose file: path outside projects root") + return errors.New("refusing to write compose file: path outside projects root") } if err := os.MkdirAll(dirPath, pkgutils.DirPerm); err != nil { @@ -92,7 +92,7 @@ func WriteProjectFile(projectsRoot, dirPath, fileName, content string) error { rootAbs = filepath.Clean(rootAbs) if !IsSafeSubdirectory(rootAbs, dirPath) { - return fmt.Errorf("refusing to write project file: path outside projects root") + return errors.New("refusing to write project file: path outside projects root") } if err := os.MkdirAll(dirPath, pkgutils.DirPerm); err != nil { @@ -125,7 +125,7 @@ func RemoveProjectFile(projectsRoot, dirPath, fileName string) error { rootAbs = filepath.Clean(rootAbs) if !IsSafeSubdirectory(rootAbs, dirPath) { - return fmt.Errorf("refusing to remove project file: path outside projects root") + return errors.New("refusing to remove project file: path outside projects root") } if fileName == "" || filepath.Base(fileName) != fileName || strings.Contains(fileName, string(filepath.Separator)) { @@ -214,6 +214,9 @@ func WriteFileWithPerm(filePath, content string, perm os.FileMode) error { type SyncFile struct { RelativePath string // Path relative to the project directory Content []byte + // Executable preserves the source's +x bit so lifecycle hooks and other + // repo-committed scripts arrive runnable in the project workspace. + Executable bool } // WriteSyncedDirectory writes multiple files to a project directory. @@ -234,7 +237,7 @@ func WriteSyncedDirectory(projectsRoot, projectPath string, files []SyncFile) ([ projectAbs = filepath.Clean(projectAbs) if !IsSafeSubdirectory(rootAbs, projectAbs) { - return nil, fmt.Errorf("project path is outside projects root") + return nil, errors.New("project path is outside projects root") } // Ensure project directory exists @@ -268,10 +271,20 @@ func WriteSyncedDirectory(projectsRoot, projectPath string, files []SyncFile) ([ return nil, fmt.Errorf("failed to inspect target path for %s: %w", file.RelativePath, err) } - // Write the file - if err := os.WriteFile(targetPathClean, file.Content, pkgutils.FilePerm); err != nil { + // Write the file. Honor the source's executable bit so scripts arrive + // runnable for lifecycle hooks and similar consumers. + perm := pkgutils.FilePerm + if file.Executable { + perm = 0o755 + } + if err := os.WriteFile(targetPathClean, file.Content, perm); err != nil { return nil, fmt.Errorf("failed to write file %s: %w", file.RelativePath, err) } + // os.WriteFile only honors the mode on file creation. Re-chmod so an + // update path also lifts/lowers the +x bit to match the repo. + if err := os.Chmod(targetPathClean, perm); err != nil { + return nil, fmt.Errorf("failed to set mode on %s: %w", file.RelativePath, err) + } writtenPaths = append(writtenPaths, file.RelativePath) } @@ -298,7 +311,7 @@ func CleanupRemovedFiles(projectsRoot, projectPath string, oldFiles, newFiles [] projectAbs = filepath.Clean(projectAbs) if !IsSafeSubdirectory(rootAbs, projectAbs) { - return fmt.Errorf("project path is outside projects root") + return errors.New("project path is outside projects root") } // Build set of new files for quick lookup diff --git a/backend/pkg/projects/fs_writer_test.go b/backend/pkg/projects/fs_writer_test.go index ac3763d615..350b1cac92 100644 --- a/backend/pkg/projects/fs_writer_test.go +++ b/backend/pkg/projects/fs_writer_test.go @@ -137,6 +137,59 @@ func TestWriteComposeFile_PreservesExistingPodmanComposeNames(t *testing.T) { } } +func TestWriteSyncedDirectory_HonorsExecutableBit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode bits are not represented on Windows FS") + } + + root := t.TempDir() + project := filepath.Join(root, "myproject") + files := []SyncFile{ + {RelativePath: "compose.yml", Content: []byte("services:\n app:\n image: alpine\n"), Executable: false}, + {RelativePath: "scripts/pre-deploy.sh", Content: []byte("#!/bin/sh\necho hi\n"), Executable: true}, + {RelativePath: "README.md", Content: []byte("readme"), Executable: false}, + } + + _, err := WriteSyncedDirectory(root, project, files) + require.NoError(t, err) + + composeInfo, err := os.Stat(filepath.Join(project, "compose.yml")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0), composeInfo.Mode().Perm()&0o111, "compose.yml should not be executable") + + scriptInfo, err := os.Stat(filepath.Join(project, "scripts/pre-deploy.sh")) + require.NoError(t, err) + assert.NotEqual(t, os.FileMode(0), scriptInfo.Mode().Perm()&0o111, "scripts/pre-deploy.sh should be executable") +} + +func TestWriteSyncedDirectory_DowngradesExecutableBit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode bits are not represented on Windows FS") + } + + root := t.TempDir() + project := filepath.Join(root, "myproject") + // First write: script committed as +x. + _, err := WriteSyncedDirectory(root, project, []SyncFile{ + {RelativePath: "scripts/hook.sh", Content: []byte("#!/bin/sh\n"), Executable: true}, + }) + require.NoError(t, err) + first, err := os.Stat(filepath.Join(project, "scripts/hook.sh")) + require.NoError(t, err) + require.NotEqual(t, os.FileMode(0), first.Mode().Perm()&0o111) + + // Second write: same file, now without +x (e.g. the repo dropped the bit). + // The write path must re-chmod so the on-disk mode tracks the repo, not + // the previous write. + _, err = WriteSyncedDirectory(root, project, []SyncFile{ + {RelativePath: "scripts/hook.sh", Content: []byte("#!/bin/sh\necho updated\n"), Executable: false}, + }) + require.NoError(t, err) + second, err := os.Stat(filepath.Join(project, "scripts/hook.sh")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0), second.Mode().Perm()&0o111, "executable bit should clear on update when repo no longer marks +x") +} + func TestWriteComposeFile_PreservesExistingCustomComposeNames(t *testing.T) { t.Parallel() diff --git a/backend/pkg/projects/image_refs.go b/backend/pkg/projects/image_refs.go new file mode 100644 index 0000000000..ccf8ef1596 --- /dev/null +++ b/backend/pkg/projects/image_refs.go @@ -0,0 +1,65 @@ +package projects + +import ( + "sort" + "strings" + + composetypes "github.com/compose-spec/compose-go/v2/types" + projecttypes "github.com/getarcaneapp/arcane/types/project" +) + +// ImageRefsFromComposeServices returns unique, non-empty image references from +// a compose service map in stable service-name order. +func ImageRefsFromComposeServices(services composetypes.Services) []string { + serviceNames := make([]string, 0, len(services)) + for name := range services { + serviceNames = append(serviceNames, name) + } + sort.Strings(serviceNames) + + serviceConfigs := make([]composetypes.ServiceConfig, 0, len(services)) + for _, name := range serviceNames { + serviceConfigs = append(serviceConfigs, services[name]) + } + + return ImageRefsFromComposeConfigs(serviceConfigs) +} + +// ImageRefsFromComposeConfigs returns unique, non-empty image references from +// compose service configs while preserving first-seen order. +func ImageRefsFromComposeConfigs(services []composetypes.ServiceConfig) []string { + return uniqueImageRefsInternal(len(services), func(yield func(string)) { + for _, svc := range services { + yield(svc.Image) + } + }) +} + +// ImageRefsFromRuntimeServices returns unique, non-empty image references from +// runtime service DTOs while preserving first-seen order. +func ImageRefsFromRuntimeServices(services []projecttypes.RuntimeService) []string { + return uniqueImageRefsInternal(len(services), func(yield func(string)) { + for _, svc := range services { + yield(svc.Image) + } + }) +} + +func uniqueImageRefsInternal(size int, collect func(yield func(string))) []string { + refs := make([]string, 0, size) + seen := make(map[string]struct{}, size) + + collect(func(image string) { + ref := strings.TrimSpace(image) + if ref == "" { + return + } + if _, exists := seen[ref]; exists { + return + } + seen[ref] = struct{}{} + refs = append(refs, ref) + }) + + return refs +} diff --git a/backend/pkg/projects/includes.go b/backend/pkg/projects/includes.go index d1bbb6b473..ca4ac04656 100644 --- a/backend/pkg/projects/includes.go +++ b/backend/pkg/projects/includes.go @@ -81,7 +81,7 @@ func ParseIncludesFromContent(composeFilePath string, content []byte, envMap Env // `include:` key present but null (e.g. `include: ~`) — treat as empty. return []IncludeFile{}, nil default: - return nil, fmt.Errorf("invalid include type") + return nil, errors.New("invalid include type") } return includeFiles, nil @@ -111,7 +111,7 @@ func extractIncludePathsInternal(item any) ([]string, error) { case map[string]any: return extractIncludePathsFromMapInternal(v) default: - return nil, fmt.Errorf("invalid include item type") + return nil, errors.New("invalid include item type") } } @@ -137,7 +137,7 @@ func extractIncludePathsFromMapInternal(v map[string]any) ([]string, error) { func resolveIncludeFileInternal(includePath, baseDir string, envMap EnvMap, includeContent bool) (IncludeFile, error) { if includePath == "" { - return IncludeFile{}, fmt.Errorf("empty include path") + return IncludeFile{}, errors.New("empty include path") } // Expand environment variables in the include path (e.g., ${PROJECT_STACK_DIR}) @@ -194,7 +194,7 @@ func readIncludeContentInternal(fullPath, includePath string, includeContent boo // Only allows writing within the project directory func ValidateIncludePathForWrite(projectDir, includePath string) (string, error) { if includePath == "" { - return "", fmt.Errorf("include path cannot be empty") + return "", errors.New("include path cannot be empty") } // Resolve project directory to absolute path and evaluate symlinks @@ -239,7 +239,7 @@ func ValidateIncludePathForWrite(projectDir, includePath string) (string, error) // Prevent targeting the project directory itself if evalPath == absProjectDir { - return "", fmt.Errorf("include path cannot be the project directory itself") + return "", errors.New("include path cannot be the project directory itself") } // Check if resolved path is within project directory @@ -247,7 +247,7 @@ func ValidateIncludePathForWrite(projectDir, includePath string) (string, error) isWithinProject := strings.HasPrefix(evalPath+string(filepath.Separator), projectPrefix) if !isWithinProject { - return "", fmt.Errorf("write access denied: path is outside project directory") + return "", errors.New("write access denied: path is outside project directory") } return absFullPath, nil diff --git a/backend/pkg/projects/load.go b/backend/pkg/projects/load.go index e90733bdf5..23e3da7a33 100644 --- a/backend/pkg/projects/load.go +++ b/backend/pkg/projects/load.go @@ -19,7 +19,7 @@ import ( "github.com/compose-spec/compose-go/v2/tree" composetypes "github.com/compose-spec/compose-go/v2/types" "github.com/docker/compose/v5/pkg/api" - units "github.com/docker/go-units" + "github.com/docker/go-units" ) var errComposeFileNotFoundInternal = errors.New("no compose file found") @@ -184,7 +184,7 @@ func wrapTypeCastMappingLenientInternal(mapping map[tree.Path]interp.Cast) map[t } func wrapCastWithLenientFallbackInternal(original interp.Cast) interp.Cast { - return func(value string) (interface{}, error) { + return func(value string) (any, error) { result, err := original(value) if err == nil { return result, nil @@ -196,11 +196,11 @@ func wrapCastWithLenientFallbackInternal(original interp.Cast) interp.Cast { } } -func lenientCastFloatInternal(value string) (interface{}, error) { +func lenientCastFloatInternal(value string) (any, error) { return strconv.ParseFloat(value, 64) } -func lenientCastSizeInternal(value string) (interface{}, error) { +func lenientCastSizeInternal(value string) (any, error) { n, err := strconv.ParseInt(value, 10, 64) if err != nil { b, parseErr := units.RAMInBytes(value) @@ -248,6 +248,7 @@ func loadComposeProjectInternal( slog.WarnContext(ctx, "Failed to load environment", "error", err) } + // Override wins: maps.Copy(dst, src) copies src into dst. maps.Copy(fullEnvMap, envOverride) // Set PWD diff --git a/backend/pkg/remenv/client.go b/backend/pkg/remenv/client.go index 7a151e8683..6e658c59f3 100644 --- a/backend/pkg/remenv/client.go +++ b/backend/pkg/remenv/client.go @@ -4,15 +4,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "maps" "net/http" "strings" "time" ) const ( - HeaderAPIKey = "X-API-Key" // #nosec G101: header name, not a credential + HeaderAPIKey = "X-Api-Key" // #nosec G101: header name, not a credential HeaderAgentToken = "X-Arcane-Agent-Token" // #nosec G101: header name, not a credential HeaderAuthorization = "Authorization" bearerScheme = "Bearer " @@ -71,14 +73,14 @@ type TunnelTransportFuncs struct { func (t TunnelTransportFuncs) EnsureAvailable(ctx context.Context, envID string) error { if t.EnsureAvailableFunc == nil { - return fmt.Errorf("edge transport unavailable") + return errors.New("edge transport unavailable") } return t.EnsureAvailableFunc(ctx, envID) } func (t TunnelTransportFuncs) Do(ctx context.Context, envID, method, path string, headers map[string]string, body []byte) (*Response, error) { if t.DoFunc == nil { - return nil, fmt.Errorf("edge transport unavailable") + return nil, errors.New("edge transport unavailable") } return t.DoFunc(ctx, envID, method, path, headers, body) } @@ -122,7 +124,7 @@ func (c *Client) DoJSON(ctx context.Context, req Request, out any) error { func (c *Client) doViaTunnelInternal(ctx context.Context, req Request) (*Response, error) { if c.tunnel == nil { - return nil, &TransportError{Err: fmt.Errorf("edge transport unavailable")} + return nil, &TransportError{Err: errors.New("edge transport unavailable")} } if err := c.tunnel.EnsureAvailable(ctx, req.EnvironmentID); err != nil { @@ -152,7 +154,7 @@ func (c *Client) doDirectHTTPInternal(ctx context.Context, req Request) (*Respon httpReq.Header.Set(key, value) } - resp, err := c.httpClient.Do(httpReq) //nolint:gosec // intentional request to configured remote environment URL + resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, &TransportError{Err: err} } @@ -190,7 +192,7 @@ func (r *Response) DecodeJSON(out any) error { return nil } if r == nil { - return &DecodeError{Err: fmt.Errorf("response is nil")} + return &DecodeError{Err: errors.New("response is nil")} } if err := json.Unmarshal(r.Body, out); err != nil { return &DecodeError{Err: err} @@ -297,8 +299,6 @@ func cloneHeaders(headers map[string]string) map[string]string { } out := make(map[string]string, len(headers)) - for key, value := range headers { - out[key] = value - } + maps.Copy(out, headers) return out } diff --git a/backend/pkg/scheduler/analytics_job.go b/backend/pkg/scheduler/analytics_job.go index 12b0a2f60b..061e752e4d 100644 --- a/backend/pkg/scheduler/analytics_job.go +++ b/backend/pkg/scheduler/analytics_job.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -12,7 +13,7 @@ import ( "sync" "time" - backoff "github.com/cenkalti/backoff/v5" + "github.com/cenkalti/backoff/v5" "github.com/getarcaneapp/arcane/backend/internal/config" "github.com/getarcaneapp/arcane/backend/internal/services" ) @@ -133,7 +134,7 @@ func (j *AnalyticsJob) Run(ctx context.Context) { } req.Header.Set("Content-Type", "application/json") - resp, err := j.httpClient.Do(req) //nolint:gosec // intentional request to configured analytics heartbeat endpoint + resp, err := j.httpClient.Do(req) if err != nil { return struct{}{}, fmt.Errorf("failed to send request: %w", err) } @@ -193,7 +194,7 @@ func (j *AnalyticsJob) Reschedule(ctx context.Context) error { func (j *AnalyticsJob) claimHeartbeatAttemptWindowInternal(ctx context.Context) (bool, error) { if j.kvService == nil { - return false, fmt.Errorf("analytics heartbeat kv service is not configured") + return false, errors.New("analytics heartbeat kv service is not configured") } j.runMu.Lock() diff --git a/backend/pkg/scheduler/auto_heal_job.go b/backend/pkg/scheduler/auto_heal_job.go index 129e591973..98c146caf9 100644 --- a/backend/pkg/scheduler/auto_heal_job.go +++ b/backend/pkg/scheduler/auto_heal_job.go @@ -9,6 +9,7 @@ import ( "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" + dockerutil "github.com/getarcaneapp/arcane/backend/pkg/dockerutil" "github.com/getarcaneapp/arcane/backend/pkg/libarcane" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" @@ -124,7 +125,7 @@ func (j *AutoHealJob) filterCandidatesInternal(containers []container.Summary, e continue } - containerName := j.getContainerName(c.Names) + containerName := dockerutil.ContainerNameFromNames(c.Names) if j.isExcluded(containerName, excludedContainers) { continue } @@ -144,7 +145,7 @@ func (j *AutoHealJob) processCandidateInternal( restartWindowMinutes int, ) { containerID := candidate.ID - containerName := j.getContainerName(candidate.Names) + containerName := dockerutil.ContainerNameFromNames(candidate.Names) inspect, err := j.inspectContainerInternal(ctx, dockerClient, containerID) if err != nil { @@ -264,14 +265,6 @@ func (j *AutoHealJob) parseExcludedContainers(ctx context.Context) map[string]st return excluded } -func (j *AutoHealJob) getContainerName(names []string) string { - if len(names) == 0 { - return "" - } - // Docker container names are prefixed with "/" - return strings.TrimPrefix(names[0], "/") -} - func (j *AutoHealJob) isExcluded(name string, excluded map[string]struct{}) bool { _, ok := excluded[name] return ok diff --git a/backend/pkg/scheduler/auto_update_job.go b/backend/pkg/scheduler/auto_update_job.go index 7f6ddefad7..9429a170de 100644 --- a/backend/pkg/scheduler/auto_update_job.go +++ b/backend/pkg/scheduler/auto_update_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" ) @@ -36,21 +34,6 @@ func (j *AutoUpdateJob) Schedule(ctx context.Context) string { if s == "" { return "0 0 0 * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 1440 - } - if i%1440 == 0 { - return fmt.Sprintf("0 0 0 */%d * *", i/1440) - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/environment_health_job.go b/backend/pkg/scheduler/environment_health_job.go index 514ac798f2..ffb39be33d 100644 --- a/backend/pkg/scheduler/environment_health_job.go +++ b/backend/pkg/scheduler/environment_health_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "sync/atomic" "time" @@ -48,18 +46,6 @@ func (j *EnvironmentHealthJob) Schedule(ctx context.Context) string { if s == "" { return "0 */2 * * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 2 - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/event_cleanup_job.go b/backend/pkg/scheduler/event_cleanup_job.go index 96429970ff..08daff1ab1 100644 --- a/backend/pkg/scheduler/event_cleanup_job.go +++ b/backend/pkg/scheduler/event_cleanup_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "time" "github.com/getarcaneapp/arcane/backend/internal/services" @@ -14,12 +12,14 @@ const EventCleanupJobName = "event-cleanup" type EventCleanupJob struct { eventService *services.EventService + activityService *services.ActivityService settingsService *services.SettingsService } -func NewEventCleanupJob(eventService *services.EventService, settingsService *services.SettingsService) *EventCleanupJob { +func NewEventCleanupJob(eventService *services.EventService, activityService *services.ActivityService, settingsService *services.SettingsService) *EventCleanupJob { return &EventCleanupJob{ eventService: eventService, + activityService: activityService, settingsService: settingsService, } } @@ -33,18 +33,6 @@ func (j *EventCleanupJob) Schedule(ctx context.Context) string { if s == "" { return "0 0 */6 * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 360 - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } @@ -61,6 +49,22 @@ func (j *EventCleanupJob) Run(ctx context.Context) { slog.InfoContext(ctx, "Event cleanup job completed successfully", "jobName", EventCleanupJobName, "olderThan", olderThan.String()) + + if j.activityService != nil { + retentionDays := j.settingsService.GetIntSetting(ctx, "activityHistoryRetentionDays", 30) + maxEntries := j.settingsService.GetIntSetting(ctx, "activityHistoryMaxEntries", 1000) + deleted, err := j.activityService.PruneHistory(ctx, retentionDays, maxEntries) + if err != nil { + slog.ErrorContext(ctx, "Failed to prune activity history", "jobName", EventCleanupJobName, "error", err) + return + } + + slog.InfoContext(ctx, "Activity history cleanup completed successfully", + "jobName", EventCleanupJobName, + "retentionDays", retentionDays, + "maxEntries", maxEntries, + "deleted", deleted) + } } func (j *EventCleanupJob) Reschedule(ctx context.Context) error { diff --git a/backend/pkg/scheduler/gitops_sync_job.go b/backend/pkg/scheduler/gitops_sync_job.go index a90ccab60e..b1ac03e953 100644 --- a/backend/pkg/scheduler/gitops_sync_job.go +++ b/backend/pkg/scheduler/gitops_sync_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/robfig/cron/v3" @@ -36,18 +34,6 @@ func (j *GitOpsSyncJob) Schedule(ctx context.Context) string { schedule = "0 */1 * * * *" } - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(schedule); err == nil { - if i <= 0 { - i = 1 - } - if i%60 == 0 { - schedule = fmt.Sprintf("0 0 */%d * * *", i/60) - } else { - schedule = fmt.Sprintf("0 */%d * * * *", i) - } - } - parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if _, err := parser.Parse(schedule); err != nil { slog.WarnContext(ctx, "Invalid cron expression for gitops-sync, using default", "invalid_schedule", schedule, "error", err) diff --git a/backend/pkg/scheduler/gitops_sync_job_test.go b/backend/pkg/scheduler/gitops_sync_job_test.go index 85fcd6da0e..fe13620b19 100644 --- a/backend/pkg/scheduler/gitops_sync_job_test.go +++ b/backend/pkg/scheduler/gitops_sync_job_test.go @@ -26,16 +26,6 @@ func TestGitOpsSyncJobSchedule_UsesConfiguredCron(t *testing.T) { require.Equal(t, "0 */7 * * * *", got) } -func TestGitOpsSyncJobSchedule_LegacyIntegerMinutes(t *testing.T) { - ctx := context.Background() - _, settingsSvc, _ := setupAnalyticsStateServicesInternal(t) - require.NoError(t, settingsSvc.SetStringSetting(ctx, "gitopsSyncInterval", "120")) - job := NewGitOpsSyncJob(nil, settingsSvc) - - got := job.Schedule(ctx) - require.Equal(t, "0 0 */2 * * *", got) -} - func TestGitOpsSyncJobSchedule_InvalidCronFallsBackToDefault(t *testing.T) { ctx := context.Background() _, settingsSvc, _ := setupAnalyticsStateServicesInternal(t) diff --git a/backend/pkg/scheduler/image_polling_job.go b/backend/pkg/scheduler/image_polling_job.go index fab417f3f2..bb5126d3fa 100644 --- a/backend/pkg/scheduler/image_polling_job.go +++ b/backend/pkg/scheduler/image_polling_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" ) @@ -36,18 +34,6 @@ func (j *ImagePollingJob) Schedule(ctx context.Context) string { if s == "" { return "0 0 * * * *" } - - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(s); err == nil { - if i <= 0 { - i = 60 - } - if i%60 == 0 { - return fmt.Sprintf("0 0 */%d * * *", i/60) - } - return fmt.Sprintf("0 */%d * * * *", i) - } - return s } diff --git a/backend/pkg/scheduler/scheduled_prune_job.go b/backend/pkg/scheduler/scheduled_prune_job.go index 6b4810ccd2..d2670b82df 100644 --- a/backend/pkg/scheduler/scheduled_prune_job.go +++ b/backend/pkg/scheduler/scheduled_prune_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/services" "github.com/getarcaneapp/arcane/types/system" @@ -41,21 +39,6 @@ func (j *ScheduledPruneJob) Schedule(ctx context.Context) string { schedule = "0 0 0 * * *" } - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(schedule); err == nil { - if i <= 0 { - i = 1440 - } - switch { - case i%1440 == 0: - schedule = fmt.Sprintf("0 0 0 */%d * *", i/1440) - case i%60 == 0: - schedule = fmt.Sprintf("0 0 */%d * * *", i/60) - default: - schedule = fmt.Sprintf("0 */%d * * * *", i) - } - } - parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if _, err := parser.Parse(schedule); err != nil { slog.WarnContext(ctx, "Invalid cron expression for scheduled-prune, using default", "invalid_schedule", schedule, "error", err) @@ -87,11 +70,15 @@ func (j *ScheduledPruneJob) Run(ctx context.Context) { "build_cache", req.BuildCache, ) - result, err := j.systemService.PruneAll(ctx, req) + result, started, err := j.systemService.PruneAll(ctx, "0", req) if err != nil { slog.ErrorContext(ctx, "scheduled prune run failed", "error", err) return } + if !started { + slog.InfoContext(ctx, "scheduled prune run skipped; prune already in progress", "activityId", result.ActivityID) + return + } slog.InfoContext(ctx, "scheduled prune run completed", "success", result.Success, diff --git a/backend/pkg/scheduler/vulnerability_scan_job.go b/backend/pkg/scheduler/vulnerability_scan_job.go index 126c59d342..090ce325d9 100644 --- a/backend/pkg/scheduler/vulnerability_scan_job.go +++ b/backend/pkg/scheduler/vulnerability_scan_job.go @@ -2,9 +2,7 @@ package scheduler import ( "context" - "fmt" "log/slog" - "strconv" "github.com/getarcaneapp/arcane/backend/internal/models" "github.com/getarcaneapp/arcane/backend/internal/services" @@ -49,21 +47,6 @@ func (j *VulnerabilityScanJob) Schedule(ctx context.Context) string { schedule = "0 0 0 * * *" } - // Handle legacy straight int if it somehow didn't get migrated - if i, err := strconv.Atoi(schedule); err == nil { - if i <= 0 { - i = 1440 - } - switch { - case i%1440 == 0: - schedule = fmt.Sprintf("0 0 0 */%d * *", i/1440) - case i%60 == 0: - schedule = fmt.Sprintf("0 0 */%d * * *", i/60) - default: - schedule = fmt.Sprintf("0 */%d * * * *", i) - } - } - parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if _, err := parser.Parse(schedule); err != nil { slog.WarnContext(ctx, "Invalid cron expression for vulnerability-scan, using default", "invalid_schedule", schedule, "error", err) diff --git a/backend/pkg/utils/activity_context.go b/backend/pkg/utils/activity_context.go new file mode 100644 index 0000000000..c13f3a6666 --- /dev/null +++ b/backend/pkg/utils/activity_context.go @@ -0,0 +1,73 @@ +package utils + +import ( + "context" + "time" +) + +type appLifecycleContextKey struct{} + +type activityRuntimeContext struct { + valueCtx context.Context + lifecycleCtx context.Context +} + +// WithAppLifecycleContext marks ctx as the application lifecycle context. +func WithAppLifecycleContext(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, appLifecycleContextKey{}, true) +} + +// IsAppLifecycleContext reports whether ctx is tied to the application lifecycle. +func IsAppLifecycleContext(ctx context.Context) bool { + isLifecycle, _ := ctx.Value(appLifecycleContextKey{}).(bool) + return isLifecycle +} + +// ActivityRuntimeContext returns a context suitable for activity-backed work. +func ActivityRuntimeContext(requestCtx context.Context, appCtx context.Context) context.Context { + if appCtx != nil { + if requestCtx == nil || requestCtx == appCtx { + return appCtx + } + return activityRuntimeContext{ + valueCtx: requestCtx, + lifecycleCtx: appCtx, + } + } + if IsAppLifecycleContext(requestCtx) { + return requestCtx + } + if requestCtx != nil { + return context.WithoutCancel(requestCtx) + } + return context.Background() +} + +func (c activityRuntimeContext) Deadline() (time.Time, bool) { + return c.lifecycleCtx.Deadline() +} + +func (c activityRuntimeContext) Done() <-chan struct{} { + return c.lifecycleCtx.Done() +} + +func (c activityRuntimeContext) Err() error { + return c.lifecycleCtx.Err() +} + +func (c activityRuntimeContext) Value(key any) any { + if _, ok := key.(appLifecycleContextKey); ok { + if value := c.lifecycleCtx.Value(key); value != nil { + return value + } + } + if c.valueCtx != nil { + if value := c.valueCtx.Value(key); value != nil { + return value + } + } + return c.lifecycleCtx.Value(key) +} diff --git a/backend/pkg/utils/activity_context_test.go b/backend/pkg/utils/activity_context_test.go new file mode 100644 index 0000000000..c9e06490dd --- /dev/null +++ b/backend/pkg/utils/activity_context_test.go @@ -0,0 +1,82 @@ +package utils + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type activityContextTestKey string + +func TestActivityRuntimeContextPrefersAppContextInternal(t *testing.T) { + requestCtx, cancelRequest := context.WithCancel(context.Background()) + appCtx, cancelApp := context.WithCancel(context.Background()) + defer cancelApp() + + ctx := ActivityRuntimeContext(requestCtx, appCtx) + + cancelRequest() + require.NoError(t, ctx.Err()) + + cancelApp() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestActivityRuntimeContextPreservesRequestValuesWithAppCancellationInternal(t *testing.T) { + requestCtx, cancelRequest := context.WithCancel(context.WithValue(context.Background(), activityContextTestKey("request-id"), "req-123")) + appCtx, cancelApp := context.WithCancel(WithAppLifecycleContext(context.Background())) + defer cancelApp() + + ctx := ActivityRuntimeContext(requestCtx, appCtx) + + require.Equal(t, "req-123", ctx.Value(activityContextTestKey("request-id"))) + require.True(t, IsAppLifecycleContext(ctx)) + + cancelRequest() + require.NoError(t, ctx.Err()) + + cancelApp() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestActivityRuntimeContextUsesAppDeadlineWithRequestValuesInternal(t *testing.T) { + requestDeadline := time.Now().Add(time.Hour) + appDeadline := time.Now().Add(time.Minute) + requestCtx, cancelRequest := context.WithDeadline(context.WithValue(context.Background(), activityContextTestKey("trace-id"), "trace-123"), requestDeadline) + defer cancelRequest() + appCtx, cancelApp := context.WithDeadline(context.Background(), appDeadline) + defer cancelApp() + + ctx := ActivityRuntimeContext(requestCtx, appCtx) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, appDeadline, deadline) + require.Equal(t, "trace-123", ctx.Value(activityContextTestKey("trace-id"))) +} + +func TestActivityRuntimeContextFallsBackToDetachedRequestContextInternal(t *testing.T) { + requestCtx, cancelRequest := context.WithCancel(context.Background()) + + ctx := ActivityRuntimeContext(requestCtx, nil) + + cancelRequest() + require.NoError(t, ctx.Err()) +} + +func TestActivityRuntimeContextPreservesMarkedAppContextInternal(t *testing.T) { + appCtx, cancelApp := context.WithCancel(WithAppLifecycleContext(context.Background())) + + ctx := ActivityRuntimeContext(appCtx, nil) + + cancelApp() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestWithAppLifecycleContextMarksContextInternal(t *testing.T) { + ctx := WithAppLifecycleContext(context.Background()) + + require.True(t, IsAppLifecycleContext(ctx)) +} diff --git a/backend/pkg/utils/auth.go b/backend/pkg/utils/auth.go index 7a60a6a6bc..ea01e29df7 100644 --- a/backend/pkg/utils/auth.go +++ b/backend/pkg/utils/auth.go @@ -1,18 +1,11 @@ package utils -import "slices" - // Auth header names and path prefixes shared between the Echo middleware // (WebSocket/diagnostics) and the Huma auth bridge (REST). Keep these in one // place so a change to a header name applies to every route type at once. const ( HeaderAgentBootstrap = "X-Arcane-Agent-Bootstrap" HeaderAgentToken = "X-Arcane-Agent-Token" // #nosec G101: header name, not a credential - HeaderApiKey = "X-API-Key" // #nosec G101: header name, not a credential + HeaderApiKey = "X-Api-Key" // #nosec G101: header name, not a credential AgentPairingPrefix = "/api/environments/0/agent/pair" ) - -// UserHasRole reports whether the user's roles contains the given role. -func UserHasRole(roles []string, role string) bool { - return slices.Contains(roles, role) -} diff --git a/backend/pkg/utils/cache/cache_util.go b/backend/pkg/utils/cache/cache_util.go index f391a33cad..edf28fcd30 100644 --- a/backend/pkg/utils/cache/cache_util.go +++ b/backend/pkg/utils/cache/cache_util.go @@ -9,12 +9,12 @@ import ( "golang.org/x/sync/singleflight" ) -type ErrStale struct { +type StaleError struct { Err error } -func (e *ErrStale) Error() string { return "stale cache value: " + e.Err.Error() } -func (e *ErrStale) Unwrap() error { return e.Err } +func (e *StaleError) Error() string { return "stale cache value: " + e.Err.Error() } +func (e *StaleError) Unwrap() error { return e.Err } type Cache[T any] struct { ttl time.Duration @@ -125,7 +125,7 @@ func (c *Cache[T]) GetOrFetch(ctx context.Context, fetch func(ctx context.Contex }) if err != nil { if hasStale { - return stale, &ErrStale{Err: err} + return stale, &StaleError{Err: err} } var zero T return zero, err diff --git a/backend/pkg/utils/cache/ttl.go b/backend/pkg/utils/cache/ttl.go index 1b5011422d..0dd8b4338e 100644 --- a/backend/pkg/utils/cache/ttl.go +++ b/backend/pkg/utils/cache/ttl.go @@ -64,3 +64,10 @@ func (c *TTL[V]) DeleteFunc(fn func(key string, value V) bool) { } } } + +// Delete removes the entry for key, if present. +func (c *TTL[V]) Delete(key string) { + c.mu.Lock() + delete(c.entries, key) + c.mu.Unlock() +} diff --git a/backend/pkg/utils/httpx/outbound_url.go b/backend/pkg/utils/httpx/outbound_url.go index cec77efd7f..654bb51859 100644 --- a/backend/pkg/utils/httpx/outbound_url.go +++ b/backend/pkg/utils/httpx/outbound_url.go @@ -1,6 +1,7 @@ package httpx import ( + "errors" "fmt" "net/url" "strings" @@ -13,7 +14,7 @@ import ( func ValidateOutboundHTTPURL(rawURL string) (*url.URL, error) { trimmed := strings.TrimSpace(rawURL) if trimmed == "" { - return nil, fmt.Errorf("URL is required") + return nil, errors.New("URL is required") } parsed, err := url.Parse(trimmed) @@ -27,11 +28,11 @@ func ValidateOutboundHTTPURL(rawURL string) (*url.URL, error) { } if parsed.User != nil { - return nil, fmt.Errorf("embedded credentials are not allowed") + return nil, errors.New("embedded credentials are not allowed") } if parsed.Host == "" || parsed.Hostname() == "" { - return nil, fmt.Errorf("URL host is required") + return nil, errors.New("URL host is required") } return parsed, nil diff --git a/backend/pkg/utils/httpx/request.go b/backend/pkg/utils/httpx/request.go index 4d179f08b4..eb01c4407e 100644 --- a/backend/pkg/utils/httpx/request.go +++ b/backend/pkg/utils/httpx/request.go @@ -8,6 +8,17 @@ import ( "strings" ) +type HeaderSetter interface { + SetHeader(key string, value string) +} + +func SetJSONStreamHeaders(headers HeaderSetter) { + headers.SetHeader("Content-Type", "application/x-json-stream") + headers.SetHeader("Cache-Control", "no-cache") + headers.SetHeader("Connection", "keep-alive") + headers.SetHeader("X-Accel-Buffering", "no") +} + // ValidateWebSocketOrigin validates the Origin header for WebSocket connections // to prevent CSRF attacks. It checks: // 1. Same-origin requests (Origin matches Host) diff --git a/backend/pkg/utils/httpx/safe_remote.go b/backend/pkg/utils/httpx/safe_remote.go index b73acf32fa..079889f583 100644 --- a/backend/pkg/utils/httpx/safe_remote.go +++ b/backend/pkg/utils/httpx/safe_remote.go @@ -126,7 +126,7 @@ func NewSafeOutboundHTTPClient(base *http.Client, lookupIP LookupIPFunc) (*http. if lastErr != nil { return nil, lastErr } - return nil, fmt.Errorf("failed to resolve remote host") + return nil, errors.New("failed to resolve remote host") } client := *base @@ -150,7 +150,11 @@ func NewSafeOutboundHTTPClient(base *http.Client, lookupIP LookupIPFunc) (*http. func cloneHTTPTransportInternal(base http.RoundTripper) (*http.Transport, error) { switch t := base.(type) { case nil: - return http.DefaultTransport.(*http.Transport).Clone(), nil + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, &common.DefaultTransportTypeError{} + } + return defaultTransport.Clone(), nil case *http.Transport: return t.Clone(), nil default: diff --git a/backend/pkg/utils/jwtclaims/jwt.go b/backend/pkg/utils/jwtclaims/jwt.go index 9b0bb992f9..e14832103e 100644 --- a/backend/pkg/utils/jwtclaims/jwt.go +++ b/backend/pkg/utils/jwtclaims/jwt.go @@ -85,15 +85,12 @@ func GetStringSliceClaim(m map[string]any, key string) []string { // CheckOrGenerateJwtSecret verifies a secret exists or generates a random one func CheckOrGenerateJwtSecret(jwtSecret string) []byte { - var secretBytes []byte if jwtSecret != "" { - secretBytes = []byte(jwtSecret) - return secretBytes - } else { - secretBytes = make([]byte, 32) - if _, err := rand.Read(secretBytes); err != nil { - panic(fmt.Errorf("failed to generate random JWT secret: %w", err)) - } + return []byte(jwtSecret) + } + secretBytes := make([]byte, 32) + if _, err := rand.Read(secretBytes); err != nil { + panic(fmt.Errorf("failed to generate random JWT secret: %w", err)) } return secretBytes } @@ -135,36 +132,3 @@ func GetByPath(m map[string]any, path string) (any, bool) { } return cur, true } - -// EvalMatch checks if a claim matches any of the desired values -func EvalMatch(v any, want []string) bool { - if len(want) == 0 { - if b, ok := v.(bool); ok { - return b - } - return false - } - wantSet := map[string]struct{}{} - for _, s := range want { - wantSet[strings.ToLower(s)] = struct{}{} - } - switch x := v.(type) { - case string: - _, ok := wantSet[strings.ToLower(x)] - return ok - case []any: - for _, it := range x { - if s, ok := it.(string); ok { - if _, ok2 := wantSet[strings.ToLower(s)]; ok2 { - return true - } - } - } - return false - case bool: - _, ok := wantSet[strings.ToLower(fmt.Sprintf("%v", x))] - return ok - default: - return false - } -} diff --git a/backend/pkg/utils/notifications/discord_sender.go b/backend/pkg/utils/notifications/discord_sender.go index cb201a5ad8..97d7024eb9 100644 --- a/backend/pkg/utils/notifications/discord_sender.go +++ b/backend/pkg/utils/notifications/discord_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -25,10 +26,10 @@ func BuildDiscordURL(config models.DiscordConfig) (string, error) { // SendDiscord sends a message via Shoutrrr Discord using proper service configuration func SendDiscord(ctx context.Context, config models.DiscordConfig, message string) error { if config.WebhookID == "" { - return fmt.Errorf("discord webhook ID is empty") + return errors.New("discord webhook ID is empty") } if config.Token == "" { - return fmt.Errorf("discord token is empty") + return errors.New("discord token is empty") } shoutrrrURL, err := BuildDiscordURL(config) diff --git a/backend/pkg/utils/notifications/email_sender.go b/backend/pkg/utils/notifications/email_sender.go index 6414200a99..d18597d09e 100644 --- a/backend/pkg/utils/notifications/email_sender.go +++ b/backend/pkg/utils/notifications/email_sender.go @@ -3,6 +3,7 @@ package notifications import ( "context" "crypto/tls" + "errors" "fmt" "time" @@ -61,7 +62,7 @@ func smtpAuthTypeFromModeInternal(mode models.EmailAuthMode) mail.SMTPAuthType { func buildMailClientInternal(config models.EmailConfig, options smtpBuildOptions) (*mail.Client, error) { if config.SMTPHost == "" { - return nil, fmt.Errorf("SMTP host is empty") + return nil, errors.New("SMTP host is empty") } if config.SMTPPort < 1 || config.SMTPPort > 65535 { return nil, fmt.Errorf("invalid SMTP port: %d", config.SMTPPort) @@ -113,17 +114,17 @@ func SendEmail(ctx context.Context, config models.EmailConfig, subject, htmlBody func sendEmailInternal(ctx context.Context, config models.EmailConfig, subject, htmlBody string, options smtpBuildOptions) error { if ctx == nil { - return fmt.Errorf("email send context is required") + return errors.New("email send context is required") } if err := ctx.Err(); err != nil { return fmt.Errorf("email send canceled: %w", err) } if config.FromAddress == "" { - return fmt.Errorf("from address is required") + return errors.New("from address is required") } if len(config.ToAddresses) == 0 { - return fmt.Errorf("at least one recipient is required") + return errors.New("at least one recipient is required") } client, err := buildMailClientInternal(config, options) diff --git a/backend/pkg/utils/notifications/generic_sender.go b/backend/pkg/utils/notifications/generic_sender.go index 8f76dff999..71e026845e 100644 --- a/backend/pkg/utils/notifications/generic_sender.go +++ b/backend/pkg/utils/notifications/generic_sender.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -21,7 +22,7 @@ import ( // BuildGenericURL and sendGenericDirectInternal. func resolveWebhookURLInternal(config models.GenericConfig) (*url.URL, error) { if config.WebhookURL == "" { - return nil, fmt.Errorf("webhook URL is empty") + return nil, errors.New("webhook URL is empty") } parsed, err := url.Parse(config.WebhookURL) @@ -43,7 +44,7 @@ func resolveWebhookURLInternal(config models.GenericConfig) (*url.URL, error) { } if parsed.Host == "" { - return nil, fmt.Errorf("invalid webhook URL: missing host") + return nil, errors.New("invalid webhook URL: missing host") } switch strings.ToLower(parsed.Scheme) { @@ -128,7 +129,7 @@ func BuildGenericURL(config models.GenericConfig) (string, error) { // but embed a success/failure indicator inside the JSON body. func SendGenericWithTitle(ctx context.Context, config models.GenericConfig, title, message string) error { if config.WebhookURL == "" { - return fmt.Errorf("webhook URL is empty") + return errors.New("webhook URL is empty") } // When the caller needs response-body validation we make the HTTP request diff --git a/backend/pkg/utils/notifications/gotify_sender.go b/backend/pkg/utils/notifications/gotify_sender.go index 36cb47a3b4..9661f5bb49 100644 --- a/backend/pkg/utils/notifications/gotify_sender.go +++ b/backend/pkg/utils/notifications/gotify_sender.go @@ -2,8 +2,10 @@ package notifications import ( "context" + "errors" "fmt" "net/url" + "strconv" "strings" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -15,11 +17,11 @@ import ( // URL example: gotify://host[:port][/path]/token[?query] func BuildGotifyURL(config models.GotifyConfig) (string, error) { if config.Host == "" { - return "", fmt.Errorf("gotify host is required") + return "", errors.New("gotify host is required") } if config.Token == "" { - return "", fmt.Errorf("gotify token is required") + return "", errors.New("gotify token is required") } u := &url.URL{ @@ -42,7 +44,7 @@ func BuildGotifyURL(config models.GotifyConfig) (string, error) { q := u.Query() // Always set priority if it's within valid range, 0 is a valid priority - q.Set("priority", fmt.Sprintf("%d", config.Priority)) + q.Set("priority", strconv.Itoa(config.Priority)) if config.Title != "" { q.Set("title", config.Title) diff --git a/backend/pkg/utils/notifications/matrix_sender.go b/backend/pkg/utils/notifications/matrix_sender.go index 26610c68fb..020eefebc6 100644 --- a/backend/pkg/utils/notifications/matrix_sender.go +++ b/backend/pkg/utils/notifications/matrix_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "net/url" "strings" @@ -15,7 +16,7 @@ import ( // URL example: matrix://user:password@host[:port]/[?rooms=!roomID1[,roomAlias2]][&disableTLS=yes] func BuildMatrixURL(config models.MatrixConfig) (string, error) { if config.Host == "" { - return "", fmt.Errorf("matrix host is required") + return "", errors.New("matrix host is required") } // Build the base URL diff --git a/backend/pkg/utils/notifications/messages.go b/backend/pkg/utils/notifications/messages.go index 2ff386dab5..43cf50c7b1 100644 --- a/backend/pkg/utils/notifications/messages.go +++ b/backend/pkg/utils/notifications/messages.go @@ -185,12 +185,12 @@ func BuildPruneReportNotificationMessage(format MessageFormat, environmentName s var message strings.Builder fmt.Fprintf(&message, "%s\n\n", formatNotificationTitleInternal(format, "🧹 System Prune Report")) fmt.Fprintf(&message, "%s %s\n", formatNotificationLabelInternal(format, "Environment"), environmentName) - fmt.Fprintf(&message, "%s %s\n\n", formatNotificationLabelInternal(format, "Total Space Reclaimed"), formatBytesInternal(result.SpaceReclaimed)) + fmt.Fprintf(&message, "%s %s\n\n", formatNotificationLabelInternal(format, "Total Space Reclaimed"), FormatBytes(result.SpaceReclaimed)) fmt.Fprintf(&message, "%s\n", formatNotificationLabelInternal(format, "Breakdown")) - fmt.Fprintf(&message, "- Containers: %s\n", formatBytesInternal(result.ContainerSpaceReclaimed)) - fmt.Fprintf(&message, "- Images: %s\n", formatBytesInternal(result.ImageSpaceReclaimed)) - fmt.Fprintf(&message, "- Volumes: %s\n", formatBytesInternal(result.VolumeSpaceReclaimed)) - fmt.Fprintf(&message, "- Build Cache: %s\n", formatBytesInternal(result.BuildCacheSpaceReclaimed)) + fmt.Fprintf(&message, "- Containers: %s\n", FormatBytes(result.ContainerSpaceReclaimed)) + fmt.Fprintf(&message, "- Images: %s\n", FormatBytes(result.ImageSpaceReclaimed)) + fmt.Fprintf(&message, "- Volumes: %s\n", FormatBytes(result.VolumeSpaceReclaimed)) + fmt.Fprintf(&message, "- Build Cache: %s\n", FormatBytes(result.BuildCacheSpaceReclaimed)) return message.String() } @@ -203,7 +203,7 @@ func BuildAutoHealNotificationMessage(format MessageFormat, environmentName, con return message.String() } -func formatBytesInternal(bytes uint64) string { +func FormatBytes(bytes uint64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) diff --git a/backend/pkg/utils/notifications/ntfy_sender.go b/backend/pkg/utils/notifications/ntfy_sender.go index fda1f926c4..662c82bd08 100644 --- a/backend/pkg/utils/notifications/ntfy_sender.go +++ b/backend/pkg/utils/notifications/ntfy_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "net/url" "strings" @@ -15,7 +16,7 @@ import ( // URL example: ntfy://[user:password@]host[:port]/topic[?query] func BuildNtfyURL(config models.NtfyConfig) (string, error) { if config.Topic == "" { - return "", fmt.Errorf("ntfy topic is required") + return "", errors.New("ntfy topic is required") } // Default host to ntfy.sh if not specified @@ -87,7 +88,7 @@ func BuildNtfyURL(config models.NtfyConfig) (string, error) { // SendNtfy sends a message via Shoutrrr Ntfy using proper service configuration func SendNtfy(ctx context.Context, config models.NtfyConfig, message string) error { if config.Topic == "" { - return fmt.Errorf("ntfy topic is required") + return errors.New("ntfy topic is required") } shoutrrrURL, err := BuildNtfyURL(config) diff --git a/backend/pkg/utils/notifications/pushover_sender.go b/backend/pkg/utils/notifications/pushover_sender.go index ef5d7ae042..72faaf6135 100644 --- a/backend/pkg/utils/notifications/pushover_sender.go +++ b/backend/pkg/utils/notifications/pushover_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "net/url" "strconv" @@ -18,13 +19,13 @@ func BuildPushoverURL(config models.PushoverConfig) (string, error) { token := strings.TrimSpace(config.Token) if user == "" { - return "", fmt.Errorf("pushover user key is required") + return "", errors.New("pushover user key is required") } if token == "" { - return "", fmt.Errorf("pushover token is required") + return "", errors.New("pushover token is required") } if config.Priority < -2 || config.Priority > 2 { - return "", fmt.Errorf("pushover priority must be between -2 and 2") + return "", errors.New("pushover priority must be between -2 and 2") } u := &url.URL{ @@ -58,13 +59,13 @@ func BuildPushoverURL(config models.PushoverConfig) (string, error) { // SendPushover sends a message via Shoutrrr Pushover using proper service configuration. func SendPushover(ctx context.Context, config models.PushoverConfig, message string) error { if strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("pushover token is empty") + return errors.New("pushover token is empty") } if strings.TrimSpace(config.User) == "" { - return fmt.Errorf("pushover user key is empty") + return errors.New("pushover user key is empty") } if config.Priority < -2 || config.Priority > 2 { - return fmt.Errorf("pushover priority must be between -2 and 2") + return errors.New("pushover priority must be between -2 and 2") } shoutrrrURL, err := BuildPushoverURL(config) diff --git a/backend/pkg/utils/notifications/signal_sender.go b/backend/pkg/utils/notifications/signal_sender.go index 63900f58d9..10d37dd80c 100644 --- a/backend/pkg/utils/notifications/signal_sender.go +++ b/backend/pkg/utils/notifications/signal_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -29,26 +30,26 @@ func BuildSignalURL(config models.SignalConfig) (string, error) { // SendSignal sends a message via Shoutrrr Signal using proper service configuration func SendSignal(ctx context.Context, config models.SignalConfig, message string) error { if config.Host == "" { - return fmt.Errorf("signal host is empty") + return errors.New("signal host is empty") } if config.Port == 0 { - return fmt.Errorf("signal port is not set") + return errors.New("signal port is not set") } if config.Source == "" { - return fmt.Errorf("signal source phone number is empty") + return errors.New("signal source phone number is empty") } if len(config.Recipients) == 0 { - return fmt.Errorf("no signal recipients configured") + return errors.New("no signal recipients configured") } // Validate authentication hasBasicAuth := config.User != "" && config.Password != "" hasTokenAuth := config.Token != "" if !hasBasicAuth && !hasTokenAuth { - return fmt.Errorf("signal requires either basic auth (user/password) or token authentication") + return errors.New("signal requires either basic auth (user/password) or token authentication") } if hasBasicAuth && hasTokenAuth { - return fmt.Errorf("signal cannot use both basic auth and token authentication simultaneously") + return errors.New("signal cannot use both basic auth and token authentication simultaneously") } shoutrrrURL, err := BuildSignalURL(config) diff --git a/backend/pkg/utils/notifications/slack_sender.go b/backend/pkg/utils/notifications/slack_sender.go index 6798ad883a..6c844fcffd 100644 --- a/backend/pkg/utils/notifications/slack_sender.go +++ b/backend/pkg/utils/notifications/slack_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -12,7 +13,7 @@ import ( // BuildSlackURL converts SlackConfig to Shoutrrr URL format using shoutrrr's Config func BuildSlackURL(config models.SlackConfig) (string, error) { if config.Token == "" { - return "", fmt.Errorf("slack token is required") + return "", errors.New("slack token is required") } // Parse the token to get the token object @@ -38,7 +39,7 @@ func BuildSlackURL(config models.SlackConfig) (string, error) { // SendSlack sends a message via Shoutrrr Slack using proper service configuration func SendSlack(ctx context.Context, config models.SlackConfig, message string) error { if config.Token == "" { - return fmt.Errorf("slack token is empty") + return errors.New("slack token is empty") } shoutrrrURL, err := BuildSlackURL(config) diff --git a/backend/pkg/utils/notifications/telegram_sender.go b/backend/pkg/utils/notifications/telegram_sender.go index 05b5af6dc0..be58243a6d 100644 --- a/backend/pkg/utils/notifications/telegram_sender.go +++ b/backend/pkg/utils/notifications/telegram_sender.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "errors" "fmt" "github.com/getarcaneapp/arcane/backend/internal/models" @@ -40,10 +41,10 @@ func BuildTelegramURL(config models.TelegramConfig) (string, error) { // SendTelegram sends a message via Shoutrrr Telegram using proper service configuration func SendTelegram(ctx context.Context, config models.TelegramConfig, message string) error { if config.BotToken == "" { - return fmt.Errorf("telegram bot token is empty") + return errors.New("telegram bot token is empty") } if len(config.ChatIDs) == 0 { - return fmt.Errorf("no telegram chat IDs configured") + return errors.New("no telegram chat IDs configured") } shoutrrrURL, err := BuildTelegramURL(config) diff --git a/backend/pkg/utils/signals/signals.go b/backend/pkg/utils/signals/signals.go index e59ca6d2da..a6aabd5282 100644 --- a/backend/pkg/utils/signals/signals.go +++ b/backend/pkg/utils/signals/signals.go @@ -24,7 +24,7 @@ var onlyOneSignalHandler = make(chan struct{}) func SignalContext(parentCtx context.Context) context.Context { close(onlyOneSignalHandler) // Panics when called twice - ctx, cancel := context.WithCancel(parentCtx) //nolint:gosec // cancel is intentionally triggered by the signal handler goroutine below. + ctx, cancel := context.WithCancel(parentCtx) sigCh := make(chan os.Signal, 2) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) diff --git a/backend/resources/embed.go b/backend/resources/embed.go index d2d103ac6b..48d373839d 100644 --- a/backend/resources/embed.go +++ b/backend/resources/embed.go @@ -4,5 +4,5 @@ import "embed" // Embedded file systems for the project -//go:embed migrations images email-templates fonts +//go:embed migrations images email-templates var FS embed.FS diff --git a/backend/resources/fonts/Mona/MonaSans.woff2 b/backend/resources/fonts/Mona/MonaSans.woff2 deleted file mode 100644 index 904b96b4bc..0000000000 Binary files a/backend/resources/fonts/Mona/MonaSans.woff2 and /dev/null differ diff --git a/backend/resources/fonts/Mona/MonaSansMono.woff2 b/backend/resources/fonts/Mona/MonaSansMono.woff2 deleted file mode 100644 index 2cf264bf22..0000000000 Binary files a/backend/resources/fonts/Mona/MonaSansMono.woff2 and /dev/null differ diff --git a/backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql b/backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql new file mode 100644 index 0000000000..786df4a835 --- /dev/null +++ b/backend/resources/migrations/postgres/053_drop_deprecated_v2.down.sql @@ -0,0 +1,10 @@ +-- v2.0.0 rollback: recreate the apprise_settings table shape (data is not restored). +CREATE TABLE IF NOT EXISTS apprise_settings ( + id SERIAL PRIMARY KEY, + api_url TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + image_update_tag VARCHAR(255), + container_update_tag VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP +); diff --git a/backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql b/backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql new file mode 100644 index 0000000000..c3bbe402d2 --- /dev/null +++ b/backend/resources/migrations/postgres/053_drop_deprecated_v2.up.sql @@ -0,0 +1,12 @@ +-- v2.0.0: drop the Apprise notification service table and deprecated settings rows. +DROP TABLE IF EXISTS apprise_settings; + +DELETE FROM settings WHERE key IN ( + 'dockerPruneMode', + 'scheduledPruneContainers', + 'scheduledPruneImages', + 'scheduledPruneVolumes', + 'scheduledPruneNetworks', + 'scheduledPruneBuildCache', + 'authOidcConfig' +); diff --git a/backend/resources/migrations/postgres/054_add_rbac.down.sql b/backend/resources/migrations/postgres/054_add_rbac.down.sql new file mode 100644 index 0000000000..0bc7893ed5 --- /dev/null +++ b/backend/resources/migrations/postgres/054_add_rbac.down.sql @@ -0,0 +1,17 @@ + +DELETE FROM settings WHERE key = 'oidcGroupsClaim'; + +DROP INDEX IF EXISTS idx_orm_claim; +DROP TABLE IF EXISTS oidc_role_mappings; + +DROP INDEX IF EXISTS idx_akp_uniq; +DROP INDEX IF EXISTS idx_akp_key; +DROP TABLE IF EXISTS api_key_permissions; + +DROP INDEX IF EXISTS idx_ura_uniq; +DROP INDEX IF EXISTS idx_ura_env; +DROP INDEX IF EXISTS idx_ura_role; +DROP INDEX IF EXISTS idx_ura_user; +DROP TABLE IF EXISTS user_role_assignments; + +DROP TABLE IF EXISTS roles; diff --git a/backend/resources/migrations/postgres/054_add_rbac.up.sql b/backend/resources/migrations/postgres/054_add_rbac.up.sql new file mode 100644 index 0000000000..811d56bfb9 --- /dev/null +++ b/backend/resources/migrations/postgres/054_add_rbac.up.sql @@ -0,0 +1,53 @@ + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + permissions JSONB NOT NULL DEFAULT '[]', + built_in BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + environment_id TEXT REFERENCES environments(id) ON DELETE CASCADE, + source TEXT NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_ura_user ON user_role_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_ura_role ON user_role_assignments(role_id); +CREATE INDEX IF NOT EXISTS idx_ura_env ON user_role_assignments(environment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_ura_uniq + ON user_role_assignments(user_id, role_id, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS api_key_permissions ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + environment_id TEXT REFERENCES environments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_akp_key ON api_key_permissions(api_key_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq + ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS oidc_role_mappings ( + id TEXT PRIMARY KEY, + claim_value TEXT NOT NULL, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + environment_id TEXT REFERENCES environments(id) ON DELETE CASCADE, + -- 'manual' = created via UI/API; 'env' = declared via OIDC_ROLE_MAPPINGS + -- env var and reconciled at boot. The API refuses mutations on 'env' rows. + source TEXT NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_orm_claim ON oidc_role_mappings(claim_value); diff --git a/backend/resources/migrations/postgres/055_add_activities.down.sql b/backend/resources/migrations/postgres/055_add_activities.down.sql new file mode 100644 index 0000000000..58d060bf56 --- /dev/null +++ b/backend/resources/migrations/postgres/055_add_activities.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS activity_messages; +DROP TABLE IF EXISTS activities; diff --git a/backend/resources/migrations/postgres/055_add_activities.up.sql b/backend/resources/migrations/postgres/055_add_activities.up.sql new file mode 100644 index 0000000000..728f41a72f --- /dev/null +++ b/backend/resources/migrations/postgres/055_add_activities.up.sql @@ -0,0 +1,40 @@ +CREATE TABLE activities ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ, + environment_id TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + resource_name TEXT, + progress INTEGER, + step TEXT, + latest_message TEXT, + started_by_user_id TEXT, + started_by_username TEXT, + started_by_display_name TEXT, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + duration_ms BIGINT, + error TEXT, + metadata TEXT +); + +CREATE INDEX idx_activities_environment_status_updated ON activities(environment_id, status, updated_at); +CREATE INDEX idx_activities_environment_started ON activities(environment_id, started_at); +CREATE INDEX idx_activities_type ON activities(type); +CREATE INDEX idx_activities_resource ON activities(resource_type, resource_id); +CREATE INDEX idx_activities_started_by_user_id ON activities(started_by_user_id); + +CREATE TABLE activity_messages ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ, + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + level TEXT NOT NULL, + message TEXT NOT NULL, + payload TEXT +); + +CREATE INDEX idx_activity_messages_activity_created ON activity_messages(activity_id, created_at); diff --git a/backend/resources/migrations/postgres/056_add_federated_credentials.down.sql b/backend/resources/migrations/postgres/056_add_federated_credentials.down.sql new file mode 100644 index 0000000000..b52076a30a --- /dev/null +++ b/backend/resources/migrations/postgres/056_add_federated_credentials.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS idx_user_sessions_federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN IF EXISTS federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN IF EXISTS source; +ALTER TABLE users DROP COLUMN IF EXISTS is_service_account; + +DROP INDEX IF EXISTS idx_federated_credentials_role_id; +DROP INDEX IF EXISTS idx_federated_credentials_identity_user_id; +DROP INDEX IF EXISTS idx_federated_credentials_enabled; +DROP INDEX IF EXISTS idx_federated_credentials_issuer_url; +DROP INDEX IF EXISTS idx_federated_token_replays_expires_at; +DROP INDEX IF EXISTS idx_federated_token_replays_issuer_url; +DROP TABLE IF EXISTS federated_token_replays; +DROP TABLE IF EXISTS federated_credentials; diff --git a/backend/resources/migrations/postgres/056_add_federated_credentials.up.sql b/backend/resources/migrations/postgres/056_add_federated_credentials.up.sql new file mode 100644 index 0000000000..23b7e33182 --- /dev/null +++ b/backend/resources/migrations/postgres/056_add_federated_credentials.up.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS federated_credentials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + issuer_url TEXT NOT NULL, + audiences TEXT NOT NULL, + subject_claim TEXT NOT NULL DEFAULT 'sub', + subject_match TEXT NOT NULL, + match_type TEXT NOT NULL DEFAULT 'exact', + role_id TEXT NOT NULL REFERENCES roles(id), + environment_id TEXT REFERENCES environments(id) ON DELETE SET NULL, + identity_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_ttl_seconds INTEGER NOT NULL DEFAULT 900, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_federated_credentials_issuer_url ON federated_credentials(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_enabled ON federated_credentials(enabled); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_identity_user_id ON federated_credentials(identity_user_id); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_role_id ON federated_credentials(role_id); + +CREATE TABLE IF NOT EXISTS federated_token_replays ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + issuer_url TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_issuer_url ON federated_token_replays(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_expires_at ON federated_token_replays(expires_at); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS source TEXT; +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS federated_credential_id TEXT REFERENCES federated_credentials(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_user_sessions_federated_credential_id ON user_sessions(federated_credential_id); diff --git a/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql new file mode 100644 index 0000000000..b1839bfd70 --- /dev/null +++ b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_output; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_status; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_at; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_network_mode; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_timeout_sec; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_extra_mounts; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_env; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_runner_image; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_script_path; diff --git a/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql new file mode 100644 index 0000000000..28f79afce7 --- /dev/null +++ b/backend/resources/migrations/postgres/057_add_gitops_sync_pre_deploy_hook.up.sql @@ -0,0 +1,20 @@ +-- Add pre-deploy lifecycle hook fields to gitops_syncs table. +-- See backend/internal/models/gitops_sync.go for field semantics. +-- pre_deploy_script_path: path inside the synced directory to a script +-- executed in a throwaway container before each deploy of the linked project. +-- pre_deploy_runner_image: image used to run the script (required when script path is set). +-- pre_deploy_env: newline-separated KEY=VALUE pairs exposed to the script as env vars. +-- pre_deploy_extra_mounts: newline-separated src:tgt[:ro|:rw] bind mounts. +-- pre_deploy_timeout_sec: hard timeout for script execution. +-- pre_deploy_network_mode: Docker network mode for the runner container; "none" denies outbound. +-- pre_deploy_last_run_*: last-run state, written by the lifecycle service. + +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_script_path TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_runner_image TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_env TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_extra_mounts TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_timeout_sec INTEGER NOT NULL DEFAULT 60; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_network_mode TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_at TIMESTAMPTZ; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_status TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_output TEXT; diff --git a/backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql new file mode 100644 index 0000000000..4cffbb72a5 --- /dev/null +++ b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.down.sql @@ -0,0 +1,10 @@ +-- v2.0.0 rollback: recreate the apprise_settings table shape (data is not restored). +CREATE TABLE IF NOT EXISTS apprise_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_url TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + image_update_tag VARCHAR(255), + container_update_tag VARCHAR(255), + created_at DATETIME, + updated_at DATETIME +); diff --git a/backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql new file mode 100644 index 0000000000..c3bbe402d2 --- /dev/null +++ b/backend/resources/migrations/sqlite/053_drop_deprecated_v2.up.sql @@ -0,0 +1,12 @@ +-- v2.0.0: drop the Apprise notification service table and deprecated settings rows. +DROP TABLE IF EXISTS apprise_settings; + +DELETE FROM settings WHERE key IN ( + 'dockerPruneMode', + 'scheduledPruneContainers', + 'scheduledPruneImages', + 'scheduledPruneVolumes', + 'scheduledPruneNetworks', + 'scheduledPruneBuildCache', + 'authOidcConfig' +); diff --git a/backend/resources/migrations/sqlite/054_add_rbac.down.sql b/backend/resources/migrations/sqlite/054_add_rbac.down.sql new file mode 100644 index 0000000000..0bc7893ed5 --- /dev/null +++ b/backend/resources/migrations/sqlite/054_add_rbac.down.sql @@ -0,0 +1,17 @@ + +DELETE FROM settings WHERE key = 'oidcGroupsClaim'; + +DROP INDEX IF EXISTS idx_orm_claim; +DROP TABLE IF EXISTS oidc_role_mappings; + +DROP INDEX IF EXISTS idx_akp_uniq; +DROP INDEX IF EXISTS idx_akp_key; +DROP TABLE IF EXISTS api_key_permissions; + +DROP INDEX IF EXISTS idx_ura_uniq; +DROP INDEX IF EXISTS idx_ura_env; +DROP INDEX IF EXISTS idx_ura_role; +DROP INDEX IF EXISTS idx_ura_user; +DROP TABLE IF EXISTS user_role_assignments; + +DROP TABLE IF EXISTS roles; diff --git a/backend/resources/migrations/sqlite/054_add_rbac.up.sql b/backend/resources/migrations/sqlite/054_add_rbac.up.sql new file mode 100644 index 0000000000..0428fc398e --- /dev/null +++ b/backend/resources/migrations/sqlite/054_add_rbac.up.sql @@ -0,0 +1,60 @@ + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + permissions TEXT NOT NULL DEFAULT '[]', + built_in BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME +); + +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + environment_id TEXT, + source TEXT NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_ura_user ON user_role_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_ura_role ON user_role_assignments(role_id); +CREATE INDEX IF NOT EXISTS idx_ura_env ON user_role_assignments(environment_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_ura_uniq + ON user_role_assignments(user_id, role_id, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS api_key_permissions ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + permission TEXT NOT NULL, + environment_id TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE, + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_akp_key ON api_key_permissions(api_key_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_akp_uniq + ON api_key_permissions(api_key_id, permission, COALESCE(environment_id, '')); + +CREATE TABLE IF NOT EXISTS oidc_role_mappings ( + id TEXT PRIMARY KEY, + claim_value TEXT NOT NULL, + role_id TEXT NOT NULL, + environment_id TEXT, + -- 'manual' = created via UI/API; 'env' = declared via OIDC_ROLE_MAPPINGS + -- env var and reconciled at boot. The API refuses mutations on 'env' rows. + source TEXT NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_orm_claim ON oidc_role_mappings(claim_value); diff --git a/backend/resources/migrations/sqlite/055_add_activities.down.sql b/backend/resources/migrations/sqlite/055_add_activities.down.sql new file mode 100644 index 0000000000..58d060bf56 --- /dev/null +++ b/backend/resources/migrations/sqlite/055_add_activities.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS activity_messages; +DROP TABLE IF EXISTS activities; diff --git a/backend/resources/migrations/sqlite/055_add_activities.up.sql b/backend/resources/migrations/sqlite/055_add_activities.up.sql new file mode 100644 index 0000000000..03a95b7193 --- /dev/null +++ b/backend/resources/migrations/sqlite/055_add_activities.up.sql @@ -0,0 +1,41 @@ +CREATE TABLE activities ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + updated_at DATETIME, + environment_id TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + resource_name TEXT, + progress INTEGER, + step TEXT, + latest_message TEXT, + started_by_user_id TEXT, + started_by_username TEXT, + started_by_display_name TEXT, + started_at DATETIME NOT NULL, + ended_at DATETIME, + duration_ms INTEGER, + error TEXT, + metadata TEXT +); + +CREATE INDEX idx_activities_environment_status_updated ON activities(environment_id, status, updated_at); +CREATE INDEX idx_activities_environment_started ON activities(environment_id, started_at); +CREATE INDEX idx_activities_type ON activities(type); +CREATE INDEX idx_activities_resource ON activities(resource_type, resource_id); +CREATE INDEX idx_activities_started_by_user_id ON activities(started_by_user_id); + +CREATE TABLE activity_messages ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + updated_at DATETIME, + activity_id TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + payload TEXT, + FOREIGN KEY(activity_id) REFERENCES activities(id) ON DELETE CASCADE +); + +CREATE INDEX idx_activity_messages_activity_created ON activity_messages(activity_id, created_at); diff --git a/backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql b/backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql new file mode 100644 index 0000000000..2173d5356c --- /dev/null +++ b/backend/resources/migrations/sqlite/056_add_federated_credentials.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS idx_user_sessions_federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN federated_credential_id; +ALTER TABLE user_sessions DROP COLUMN source; +ALTER TABLE users DROP COLUMN is_service_account; + +DROP INDEX IF EXISTS idx_federated_credentials_role_id; +DROP INDEX IF EXISTS idx_federated_credentials_identity_user_id; +DROP INDEX IF EXISTS idx_federated_credentials_enabled; +DROP INDEX IF EXISTS idx_federated_credentials_issuer_url; +DROP INDEX IF EXISTS idx_federated_token_replays_expires_at; +DROP INDEX IF EXISTS idx_federated_token_replays_issuer_url; +DROP TABLE IF EXISTS federated_token_replays; +DROP TABLE IF EXISTS federated_credentials; diff --git a/backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql b/backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql new file mode 100644 index 0000000000..9c171e97d2 --- /dev/null +++ b/backend/resources/migrations/sqlite/056_add_federated_credentials.up.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS federated_credentials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT 0, + issuer_url TEXT NOT NULL, + audiences TEXT NOT NULL, + subject_claim TEXT NOT NULL DEFAULT 'sub', + subject_match TEXT NOT NULL, + match_type TEXT NOT NULL DEFAULT 'exact', + role_id TEXT NOT NULL, + environment_id TEXT, + identity_user_id TEXT NOT NULL, + token_ttl_seconds INTEGER NOT NULL DEFAULT 900, + last_used_at DATETIME, + expires_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME, + FOREIGN KEY (role_id) REFERENCES roles(id), + FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE SET NULL, + FOREIGN KEY (identity_user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_federated_credentials_issuer_url ON federated_credentials(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_enabled ON federated_credentials(enabled); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_identity_user_id ON federated_credentials(identity_user_id); +CREATE INDEX IF NOT EXISTS idx_federated_credentials_role_id ON federated_credentials(role_id); + +CREATE TABLE IF NOT EXISTS federated_token_replays ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + issuer_url TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_issuer_url ON federated_token_replays(issuer_url); +CREATE INDEX IF NOT EXISTS idx_federated_token_replays_expires_at ON federated_token_replays(expires_at); + +ALTER TABLE users ADD COLUMN is_service_account BOOLEAN NOT NULL DEFAULT 0; +ALTER TABLE user_sessions ADD COLUMN source TEXT; +ALTER TABLE user_sessions ADD COLUMN federated_credential_id TEXT REFERENCES federated_credentials(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_user_sessions_federated_credential_id ON user_sessions(federated_credential_id); diff --git a/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql new file mode 100644 index 0000000000..b1839bfd70 --- /dev/null +++ b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_output; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_status; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_last_run_at; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_network_mode; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_timeout_sec; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_extra_mounts; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_env; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_runner_image; +ALTER TABLE gitops_syncs DROP COLUMN pre_deploy_script_path; diff --git a/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql new file mode 100644 index 0000000000..6931cbb34e --- /dev/null +++ b/backend/resources/migrations/sqlite/057_add_gitops_sync_pre_deploy_hook.up.sql @@ -0,0 +1,20 @@ +-- Add pre-deploy lifecycle hook fields to gitops_syncs table. +-- See backend/internal/models/gitops_sync.go for field semantics. +-- pre_deploy_script_path: path inside the synced directory to a script +-- executed in a throwaway container before each deploy of the linked project. +-- pre_deploy_runner_image: image used to run the script (required when script path is set). +-- pre_deploy_env: newline-separated KEY=VALUE pairs exposed to the script as env vars. +-- pre_deploy_extra_mounts: newline-separated src:tgt[:ro|:rw] bind mounts. +-- pre_deploy_timeout_sec: hard timeout for script execution. +-- pre_deploy_network_mode: Docker network mode for the runner container; "none" denies outbound. +-- pre_deploy_last_run_*: last-run state, written by the lifecycle service. + +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_script_path TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_runner_image TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_env TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_extra_mounts TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_timeout_sec INTEGER NOT NULL DEFAULT 60; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_network_mode TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_at DATETIME; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_status TEXT; +ALTER TABLE gitops_syncs ADD COLUMN pre_deploy_last_run_output TEXT; diff --git a/cli/go.mod b/cli/go.mod index 07b85167e3..4c1d021957 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,12 +5,12 @@ go 1.26.3 require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 + charm.land/fang/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.3 - github.com/charmbracelet/fang v1.0.0 - github.com/charmbracelet/log v1.0.0 + charm.land/log/v2 v2.0.0 github.com/charmbracelet/x/term v0.2.2 github.com/fatih/color v1.19.0 - github.com/getarcaneapp/arcane/types v1.19.4 + github.com/getarcaneapp/arcane/types v1.19.5 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 @@ -22,15 +22,12 @@ require ( require ( github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20260511125431-fe5d686e0c99 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect @@ -59,7 +56,6 @@ require ( github.com/muesli/mango-cobra v1.3.0 // indirect github.com/muesli/mango-pflag v0.2.0 // indirect github.com/muesli/roff v0.1.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect @@ -73,13 +69,13 @@ require ( github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index 28e1587a58..d89cf1f2d2 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -2,36 +2,30 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= -github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= -github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= -github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow= -github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20260511125431-fe5d686e0c99 h1:79Whx3H/thq9X9I+iqsi7o/pVaI7EhaIWbzB173eHsw= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20260511125431-fe5d686e0c99/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d h1:sMilwx1YIYTrQva6jsB522AoRYAerNaDIKP4ZPtUq0A= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -67,8 +61,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/getarcaneapp/arcane/types v1.19.4 h1:THWCdJPoUjM+PJYo/02eb0Q56xSiHOE8Xt2zxdP6BfY= -github.com/getarcaneapp/arcane/types v1.19.4/go.mod h1:YY2TkFWZlMNu5dwp8dIDivLXl8dd9TE+P55Mk+6CaZ8= +github.com/getarcaneapp/arcane/types v1.19.5 h1:NmdBfeQhJ+OETxnk2crN82DfQJ3DTbruWLZSq/JkE1c= +github.com/getarcaneapp/arcane/types v1.19.5/go.mod h1:Ts4t4KmeMu5S1Qc5Mg9qjvYft9O3EYnlfEzSi+9GNKE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -116,8 +110,6 @@ github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0 github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -158,26 +150,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.withmatt.com/size v0.0.0-20250220224316-11aee5773e67 h1:nHPqmzoLZv+9OqER7S2oXnIBDAbIaIKKrVtNtDvJzjc= go.withmatt.com/size v0.0.0-20250220224316-11aee5773e67/go.mod h1:WFmSrzphcXaSV4LG3YfIw42bPHDK8A6WsAYZCxyys/c= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 h1:cHpkPjp4TILjdZxz/O4ykwCpeS+dDqNuDGse4zgQDCk= +golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= diff --git a/cli/internal/ci/ci_detect.go b/cli/internal/ci/ci_detect.go new file mode 100644 index 0000000000..2fd408addb --- /dev/null +++ b/cli/internal/ci/ci_detect.go @@ -0,0 +1,117 @@ +package ci + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + ProviderAuto = "auto" + ProviderGitHub = "github" + ProviderGitLab = "gitlab" + ProviderGeneric = "generic" +) + +// DetectToken resolves a CI-issued OIDC token from the requested provider. +func DetectToken(ctx context.Context, provider string, audience string, getenv func(string) string, httpClient *http.Client) (string, string, error) { + provider = normalizeFederatedProviderInternal(provider) + if getenv == nil { + getenv = func(string) string { return "" } + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + switch provider { + case ProviderAuto: + if token, err := mintGitHubActionsTokenInternal(ctx, audience, getenv, httpClient); err == nil { + return token, ProviderGitHub, nil + } + if token := strings.TrimSpace(getenv("CI_JOB_JWT_V2")); token != "" { + return token, ProviderGitLab, nil + } + return "", "", errors.New("no supported CI OIDC token source detected") + case ProviderGitHub: + token, err := mintGitHubActionsTokenInternal(ctx, audience, getenv, httpClient) + if err != nil { + return "", "", err + } + return token, ProviderGitHub, nil + case ProviderGitLab: + token := strings.TrimSpace(getenv("CI_JOB_JWT_V2")) + if token == "" { + return "", "", errors.New("CI_JOB_JWT_V2 is not set; pass a GitLab id_tokens value with --token") + } + return token, ProviderGitLab, nil + case ProviderGeneric: + return "", "", errors.New("generic provider requires --token, --token-file, or --token-stdin") + default: + return "", "", fmt.Errorf("unsupported federated provider %q", provider) + } +} + +func mintGitHubActionsTokenInternal(ctx context.Context, audience string, getenv func(string) string, httpClient *http.Client) (string, error) { + requestURL := strings.TrimSpace(getenv("ACTIONS_ID_TOKEN_REQUEST_URL")) + requestToken := strings.TrimSpace(getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")) + if requestURL == "" || requestToken == "" { + return "", errors.New("GitHub Actions OIDC request environment is not set") + } + + parsedURL, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("invalid GitHub Actions OIDC request URL: %w", err) + } + if strings.TrimSpace(audience) != "" { + q := parsedURL.Query() + q.Set("audience", strings.TrimSpace(audience)) + parsedURL.RawQuery = q.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create GitHub Actions OIDC request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+requestToken) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to request GitHub Actions OIDC token: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return "", fmt.Errorf("failed to read GitHub Actions OIDC response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("GitHub Actions OIDC request failed with status %d", resp.StatusCode) + } + + var payload struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", fmt.Errorf("failed to parse GitHub Actions OIDC response: %w", err) + } + token := strings.TrimSpace(payload.Value) + if token == "" { + return "", errors.New("GitHub Actions OIDC response did not include a token") + } + return token, nil +} + +func normalizeFederatedProviderInternal(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return ProviderAuto + } + return provider +} diff --git a/cli/internal/ci/ci_detect_test.go b/cli/internal/ci/ci_detect_test.go new file mode 100644 index 0000000000..c33c08381d --- /dev/null +++ b/cli/internal/ci/ci_detect_test.go @@ -0,0 +1,64 @@ +package ci + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDetectFederatedTokenFromGitHubActionsInternal(t *testing.T) { + t.Parallel() + + var gotAudience string + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAudience = r.URL.Query().Get("audience") + gotAuth = r.Header.Get("Authorization") + require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"value": "github.jwt"})) + })) + t.Cleanup(server.Close) + + env := map[string]string{ + "ACTIONS_ID_TOKEN_REQUEST_URL": server.URL + "?api-version=2", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + } + token, provider, err := DetectToken(context.Background(), "auto", "https://arcane.example.com", envGetterFromMapInternal(env), server.Client()) + + require.NoError(t, err) + require.Equal(t, "github.jwt", token) + require.Equal(t, "github", provider) + require.Equal(t, "https://arcane.example.com", gotAudience) + require.Equal(t, "Bearer request-token", gotAuth) +} + +func TestDetectFederatedTokenFromGitLabLegacyInternal(t *testing.T) { + t.Parallel() + + token, provider, err := DetectToken(context.Background(), "auto", "https://arcane.example.com", envGetterFromMapInternal(map[string]string{ + "CI_JOB_JWT_V2": "gitlab.jwt", + }), http.DefaultClient) + + require.NoError(t, err) + require.Equal(t, "gitlab.jwt", token) + require.Equal(t, "gitlab", provider) +} + +func TestDetectFederatedTokenProviderMismatchInternal(t *testing.T) { + t.Parallel() + + _, _, err := DetectToken(context.Background(), "github", "aud", envGetterFromMapInternal(map[string]string{ + "CI_JOB_JWT_V2": "gitlab.jwt", + }), http.DefaultClient) + + require.Error(t, err) +} + +func envGetterFromMapInternal(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index 9908684314..d875577bfd 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -32,6 +32,7 @@ import ( "net" "net/http" "net/url" + "os" "strings" "time" @@ -44,7 +45,7 @@ import ( ) const ( - headerAPIKey = "X-API-KEY" //nolint:gosec + headerAPIKey = "X-Api-Key" // #nosec G101 -- HTTP header name, not a credential defaultTimeout = 10 * time.Minute defaultEnvID = "0" maxErrorBody = 4096 @@ -160,6 +161,11 @@ func NewFromConfig() (*Client, error) { if strings.TrimSpace(state.EnvOverride) != "" { cfg.DefaultEnvironment = strings.TrimSpace(state.EnvOverride) } + if token := strings.TrimSpace(os.Getenv("ARCANE_TOKEN")); token != "" { + cfg.JWTToken = token + cfg.APIKey = "" + cfg.RefreshToken = "" + } c, err := New(cfg) if err != nil { return nil, err @@ -180,6 +186,11 @@ func NewFromConfigUnauthenticated() (*Client, error) { if strings.TrimSpace(state.EnvOverride) != "" { cfg.DefaultEnvironment = strings.TrimSpace(state.EnvOverride) } + if token := strings.TrimSpace(os.Getenv("ARCANE_TOKEN")); token != "" { + cfg.JWTToken = token + cfg.APIKey = "" + cfg.RefreshToken = "" + } c, err := NewUnauthenticated(cfg) if err != nil { return nil, err @@ -269,7 +280,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body any) (*h req.Header.Set("Accept", "application/json") start := time.Now() logger.GetLogger().Debug("Sending request", "method", method, "url", fullURL, "env_id", c.envID, "streaming_body", true) - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Request failed", "method", method, "url", fullURL, "env_id", c.envID, "duration", time.Since(start).String(), "error", err) return nil, fmt.Errorf("request failed: %w", err) @@ -312,7 +323,7 @@ func (c *Client) RequestRaw(ctx context.Context, method, path string, body io.Re start := time.Now() logger.GetLogger().Debug("Sending raw request", "method", method, "url", fullURL, "env_id", c.envID) - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Raw request failed", "method", method, "url", fullURL, "env_id", c.envID, "duration", time.Since(start).String(), "error", err) return nil, fmt.Errorf("request failed: %w", err) @@ -324,7 +335,7 @@ func (c *Client) RequestRaw(ctx context.Context, method, path string, body io.Re func (c *Client) resolveURL(path string) (string, error) { if strings.TrimSpace(path) == "" { - return "", fmt.Errorf("invalid path: empty") + return "", errors.New("invalid path: empty") } if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { if _, err := url.Parse(path); err != nil { @@ -337,7 +348,7 @@ func (c *Client) resolveURL(path string) (string, error) { return "", fmt.Errorf("invalid path: %w", err) } if c.baseURLParsed == nil { - return "", fmt.Errorf("invalid base URL") + return "", errors.New("invalid base URL") } return c.baseURLParsed.ResolveReference(rel).String(), nil } @@ -362,7 +373,7 @@ func (c *Client) doRequest(ctx context.Context, method, fullURL string, bodyByte start := time.Now() logger.GetLogger().Debug("Sending request", "method", method, "url", fullURL, "env_id", c.envID, "attempt", attempt, "max_attempts", attempts, "body_bytes", len(bodyBytes)) - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Request failed", "method", method, "url", fullURL, "env_id", c.envID, "attempt", attempt, "duration", time.Since(start).String(), "error", err) if attempt < attempts && c.shouldRetry(method, 0, err) { @@ -467,7 +478,7 @@ func (c *Client) applyAuth(req *http.Request) { func (c *Client) refreshAccessToken(ctx context.Context) error { if c.refreshToken == "" { - return fmt.Errorf("refresh token not configured; run `arcane auth login`") + return errors.New("refresh token not configured; run `arcane auth login`") } bodyBytes, err := json.Marshal(map[string]string{"refreshToken": c.refreshToken}) @@ -476,7 +487,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { } if c.baseURLParsed == nil { - return fmt.Errorf("invalid base URL") + return errors.New("invalid base URL") } refreshURL := c.baseURLParsed.ResolveReference(&url.URL{Path: types.Endpoints.AuthRefresh()}).String() logger.GetLogger().Debug("Sending token refresh request", "url", refreshURL) @@ -489,7 +500,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { req.Header.Set("Accept", "application/json") start := time.Now() - resp, err := c.httpClient.Do(req) //nolint:gosec // intentional request to configured Arcane server URL + resp, err := c.httpClient.Do(req) if err != nil { logger.GetLogger().Debug("Token refresh request failed", "url", refreshURL, "duration", time.Since(start).String(), "error", err) return fmt.Errorf("token refresh failed: %w", err) @@ -511,7 +522,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error { return fmt.Errorf("failed to parse refresh response: %w", err) } if !result.Success || result.Data.Token == "" { - return fmt.Errorf("token refresh failed: unexpected response from server") + return errors.New("token refresh failed: unexpected response from server") } newRefresh := result.Data.RefreshToken @@ -647,7 +658,7 @@ func DecodeResponseStrict[T any](resp *http.Response) (*APIResponse[T], error) { if strings.TrimSpace(result.Error) != "" { return &result, fmt.Errorf("API error: %s", result.Error) } - return &result, fmt.Errorf("API error: request was not successful") + return &result, errors.New("API error: request was not successful") } return &result, nil diff --git a/cli/internal/cmdutil/context.go b/cli/internal/cmdutil/context.go index 0cb8db657f..3e6e140e60 100644 --- a/cli/internal/cmdutil/context.go +++ b/cli/internal/cmdutil/context.go @@ -1,6 +1,7 @@ package cmdutil import ( + "errors" "fmt" "strings" @@ -12,7 +13,7 @@ import ( // ClientFromCommand returns a configured authenticated client for the command. func ClientFromCommand(cmd *cobra.Command) (*client.Client, error) { if cmd == nil { - return nil, fmt.Errorf("nil command") + return nil, errors.New("nil command") } if app, ok := runtimectx.From(cmd.Context()); ok { return app.Client() @@ -23,7 +24,7 @@ func ClientFromCommand(cmd *cobra.Command) (*client.Client, error) { // UnauthClientFromCommand returns a configured unauthenticated client for the command. func UnauthClientFromCommand(cmd *cobra.Command) (*client.Client, error) { if cmd == nil { - return nil, fmt.Errorf("nil command") + return nil, errors.New("nil command") } if app, ok := runtimectx.From(cmd.Context()); ok { return app.UnauthClient() diff --git a/cli/internal/cmdutil/response.go b/cli/internal/cmdutil/response.go index c2f854b4a6..8a2bfe7c58 100644 --- a/cli/internal/cmdutil/response.go +++ b/cli/internal/cmdutil/response.go @@ -2,6 +2,7 @@ package cmdutil import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -27,7 +28,7 @@ func (e *HTTPStatusError) Error() string { // EnsureSuccessStatus returns an error for non-2xx responses. func EnsureSuccessStatus(resp *http.Response) error { if resp == nil { - return fmt.Errorf("nil HTTP response") + return errors.New("nil HTTP response") } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil @@ -45,7 +46,7 @@ func DecodeJSON[T any](resp *http.Response, out *T) error { return err } if out == nil { - return fmt.Errorf("nil decode target") + return errors.New("nil decode target") } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("failed to parse response: %w", err) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 738604833d..e7379cec64 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -68,36 +68,9 @@ func normalizeConfig(cfg *types.Config) *types.Config { return DefaultConfig() } - if normalized.Pagination.Default.Limit <= 0 && normalized.DefaultLimit > 0 { - normalized.Pagination.Default.Limit = normalized.DefaultLimit - } - if normalized.DefaultLimit <= 0 && normalized.Pagination.Default.Limit > 0 { - normalized.DefaultLimit = normalized.Pagination.Default.Limit - } - if normalized.Pagination.Resources == nil { normalized.Pagination.Resources = make(map[string]types.PaginationResourceConfig) } - if normalized.ResourceLimits == nil { - normalized.ResourceLimits = make(map[string]int) - } - - for resource, limit := range normalized.ResourceLimits { - resource = types.NormalizePaginatedResource(resource) - if resource == "" || limit <= 0 { - continue - } - if _, exists := normalized.Pagination.Resources[resource]; !exists { - normalized.Pagination.Resources[resource] = types.PaginationResourceConfig{Limit: limit} - } - } - for resource, cfg := range normalized.Pagination.Resources { - resource = types.NormalizePaginatedResource(resource) - if resource == "" || cfg.Limit <= 0 { - continue - } - normalized.ResourceLimits[resource] = cfg.Limit - } return normalized } @@ -205,6 +178,7 @@ func Load() (*types.Config, error) { v.SetConfigType("yaml") v.SetDefault("server_url", "http://localhost:3552") v.SetDefault("default_environment", "0") + v.SetDefault("federated_audience", "") v.SetDefault("log_level", "info") if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) @@ -259,6 +233,9 @@ func Save(c *types.Config) error { if cfg.DefaultEnvironment != "" { v.Set("default_environment", cfg.DefaultEnvironment) } + if cfg.FederatedAudience != "" { + v.Set("federated_audience", cfg.FederatedAudience) + } if cfg.LogLevel != "" { v.Set("log_level", cfg.LogLevel) } @@ -278,24 +255,6 @@ func Save(c *types.Config) error { v.Set(fmt.Sprintf("pagination.resources.%s.limit", resource), rc.Limit) } - // Legacy keys retained for backward compatibility. - if cfg.DefaultLimit > 0 { - v.Set("default_limit", cfg.DefaultLimit) - } - if len(cfg.ResourceLimits) > 0 { - legacy := make(map[string]int) - for resource, limit := range cfg.ResourceLimits { - resource = types.NormalizePaginatedResource(resource) - if resource == "" || limit <= 0 { - continue - } - legacy[resource] = limit - } - if len(legacy) > 0 { - v.Set("resource_limits", legacy) - } - } - if err := v.WriteConfigAs(path); err != nil { return fmt.Errorf("failed to write config file: %w", err) } @@ -346,6 +305,7 @@ func InitDefaultFile() (bool, error) { v.Set("jwt_token", "") v.Set("refresh_token", "") v.Set("default_environment", "0") + v.Set("federated_audience", "") v.Set("log_level", "info") v.Set("pagination.default.limit", defaultPaginationInitLimit) @@ -353,13 +313,6 @@ func InitDefaultFile() (bool, error) { v.Set(fmt.Sprintf("pagination.resources.%s.limit", resource), defaultPaginationInitLimit) } - v.Set("default_limit", defaultPaginationInitLimit) - legacy := make(map[string]int, len(types.KnownPaginatedResources)) - for _, resource := range types.KnownPaginatedResources { - legacy[resource] = defaultPaginationInitLimit - } - v.Set("resource_limits", legacy) - if err := v.WriteConfigAs(path); err != nil { return false, fmt.Errorf("failed to write config file: %w", err) } diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 2af0f6e1a9..723ebec8a6 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -53,7 +53,7 @@ func TestLoadReturnsDefaultsWhenFileMissing(t *testing.T) { } } -func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { +func TestSaveAndLoadRoundTripPagination(t *testing.T) { path := setTempConfigPath(t) cfg := DefaultConfig() @@ -75,12 +75,6 @@ func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { if !strings.Contains(text, "pagination:") { t.Fatalf("expected saved YAML to include pagination block:\n%s", text) } - if !strings.Contains(text, "default_limit: 42") { - t.Fatalf("expected saved YAML to include legacy default_limit key:\n%s", text) - } - if !strings.Contains(text, "resource_limits:") { - t.Fatalf("expected saved YAML to include legacy resource_limits key:\n%s", text) - } if !strings.Contains(text, "cli_update_channel: next") { t.Fatalf("expected saved YAML to include cli_update_channel key:\n%s", text) } @@ -97,8 +91,8 @@ func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { if err != nil { t.Fatalf("Load() failed: %v", err) } - if loaded.Pagination.Default.Limit != 42 || loaded.DefaultLimit != 42 { - t.Fatalf("default limit mismatch: pagination=%d legacy=%d", loaded.Pagination.Default.Limit, loaded.DefaultLimit) + if loaded.Pagination.Default.Limit != 42 { + t.Fatalf("default limit mismatch: pagination=%d, want 42", loaded.Pagination.Default.Limit) } if got := loaded.LimitFor("images"); got != 17 { t.Fatalf("images limit=%d, want 17", got) @@ -111,39 +105,6 @@ func TestSaveAndLoadRoundTripPaginationCompatibility(t *testing.T) { } } -func TestLoadLegacyPaginationKeys(t *testing.T) { - path := setTempConfigPath(t) - content := ` -server_url: https://arcane.example -default_environment: "7" -log_level: warn -default_limit: 25 -resource_limits: - image: 31 - Volumes: 5 -` - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatalf("failed to write config fixture: %v", err) - } - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - if cfg.ServerURL != "https://arcane.example" { - t.Fatalf("ServerURL=%q, want %q", cfg.ServerURL, "https://arcane.example") - } - if cfg.Pagination.Default.Limit != 25 || cfg.DefaultLimit != 25 { - t.Fatalf("default limit mismatch: pagination=%d legacy=%d", cfg.Pagination.Default.Limit, cfg.DefaultLimit) - } - if got := cfg.LimitFor("images"); got != 31 { - t.Fatalf("images limit=%d, want 31", got) - } - if got := cfg.LimitFor("volumes"); got != 5 { - t.Fatalf("volumes limit=%d, want 5", got) - } -} - func TestLoadCanonicalPaginationBlock(t *testing.T) { path := setTempConfigPath(t) content := ` @@ -210,8 +171,6 @@ func TestInitDefaultFileCreatesTemplate(t *testing.T) { "default_environment:", "log_level:", "pagination:", - "default_limit:", - "resource_limits:", } for _, key := range requiredKeys { if !strings.Contains(text, key) { @@ -231,9 +190,6 @@ func TestInitDefaultFileCreatesTemplate(t *testing.T) { if cfg.Pagination.Default.Limit != defaultPaginationInitLimit { t.Fatalf("Pagination.Default.Limit=%d, want %d", cfg.Pagination.Default.Limit, defaultPaginationInitLimit) } - if cfg.DefaultLimit != defaultPaginationInitLimit { - t.Fatalf("DefaultLimit=%d, want %d", cfg.DefaultLimit, defaultPaginationInitLimit) - } for _, resource := range []string{"containers", "images", "volumes", "networks", "projects", "environments", "registries", "templates", "users", "events", "apikeys"} { if got := cfg.LimitFor(resource); got != defaultPaginationInitLimit { t.Fatalf("LimitFor(%s)=%d, want %d", resource, got, defaultPaginationInitLimit) diff --git a/cli/internal/integrationtest/projects_json_compat_test.go b/cli/internal/integrationtest/projects_json_compat_test.go deleted file mode 100644 index 0614a8f99f..0000000000 --- a/cli/internal/integrationtest/projects_json_compat_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package integrationtest - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestProjectsJSONCompatibilityForComposeUnitBytes(t *testing.T) { - projectResponse := `{ - "success": true, - "data": { - "id": "project-1", - "name": "myproject", - "path": "/tmp/myproject", - "status": "running", - "serviceCount": 1, - "runningCount": 1, - "createdAt": "2026-03-13T00:00:00Z", - "updatedAt": "2026-03-13T00:00:00Z", - "services": [ - { - "name": "myapp", - "image": "nginx:latest", - "mem_limit": "256m", - "build": { - "context": ".", - "shm_size": "64m" - }, - "deploy": { - "resources": { - "limits": { - "memory": "512m" - } - } - } - } - ], - "runtimeServices": [ - { - "name": "myapp", - "image": "nginx:latest", - "status": "running", - "serviceConfig": { - "name": "myapp", - "image": "nginx:latest", - "mem_limit": "256m" - } - } - ] - } - }` - - downCalled := false - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/environments/0/projects/myproject": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(projectResponse)) - case r.Method == http.MethodPost && r.URL.Path == "/api/environments/0/projects/project-1/down": - downCalled = true - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"data":{"message":"stopped"}}`)) - default: - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"success":false,"error":"not found"}`)) - } - })) - defer srv.Close() - - configPath := writeCLIIntegrationConfigInternal(t, srv.URL) - - getStdout, getStderr, err := executeCLIIntegrationCommandInternal( - t, - []string{"--config", configPath, "projects", "get", "myproject", "--json"}, - ) - if err != nil { - t.Fatalf("projects get failed: %v (%s)", err, getStderr) - } - - var decoded map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(getStdout)), &decoded); err != nil { - t.Fatalf("projects get produced invalid JSON: %v\noutput=%s", err, getStdout) - } - - services, ok := decoded["services"].([]any) - if !ok || len(services) != 1 { - t.Fatalf("expected one service in JSON output, got %#v", decoded["services"]) - } - - serviceMap, ok := services[0].(map[string]any) - if !ok { - t.Fatalf("expected service entry to be a map, got %#v", services[0]) - } - - if got := serviceMap["mem_limit"]; got != "268435456" { - t.Fatalf("expected mem_limit to round-trip as normalized bytes, got %#v", got) - } - - _, downStderr, err := executeCLIIntegrationCommandInternal( - t, - []string{"--config", configPath, "projects", "down", "myproject"}, - ) - if err != nil { - t.Fatalf("projects down failed: %v (%s)", err, downStderr) - } - if !downCalled { - t.Fatal("expected projects down to issue a POST after resolving the project") - } -} diff --git a/cli/internal/logger/logger.go b/cli/internal/logger/logger.go index a7283b9d57..cce6f42be6 100644 --- a/cli/internal/logger/logger.go +++ b/cli/internal/logger/logger.go @@ -22,7 +22,7 @@ package logger import ( "os" - charmlog "github.com/charmbracelet/log" + charmlog "charm.land/log/v2" ) // Setup configures the global logger with the specified level and format. diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go index 4e54b679d3..6182042274 100644 --- a/cli/internal/output/output.go +++ b/cli/internal/output/output.go @@ -102,6 +102,8 @@ func SetColorEnabled(enabled bool) { // Success prints a success message in green. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Success(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(successStyle, msg)) @@ -110,6 +112,8 @@ func Success(format string, a ...any) { // Error prints an error message in red. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Error(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(errorStyle, msg)) @@ -118,6 +122,8 @@ func Error(format string, a ...any) { // Warning prints a warning message in yellow. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Warning(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(warnStyle, msg)) @@ -126,6 +132,8 @@ func Warning(format string, a ...any) { // Info prints an info message in cyan. // The message is prefixed with a newline for visual separation. // Format specifiers and arguments work like fmt.Printf. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Info(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(infoStyle, msg)) @@ -134,6 +142,8 @@ func Info(format string, a ...any) { // Header prints a header message in bold white. // Use this to introduce sections of output. The message is prefixed // with a newline for visual separation. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Header(format string, a ...any) { msg := fmt.Sprintf(format, a...) fmt.Printf("\n%s\n", render(headerStyle, msg)) @@ -141,6 +151,8 @@ func Header(format string, a ...any) { // Print prints a standard message without color formatting. // Use this for regular output that doesn't need status indication. +// +//nolint:goprintffuncname // printf-style output helper; *f rename across call sites tracked separately func Print(format string, a ...any) { fmt.Printf(format+"\n", a...) } diff --git a/cli/internal/prompt/prompt.go b/cli/internal/prompt/prompt.go index cd2e46b699..f07347b43b 100644 --- a/cli/internal/prompt/prompt.go +++ b/cli/internal/prompt/prompt.go @@ -1,6 +1,7 @@ package prompt import ( + "errors" "fmt" "os" @@ -20,7 +21,7 @@ func Select(label string, options []string) (int, error) { return -1, fmt.Errorf("no %s options available", label) } if !IsInteractive() { - return -1, fmt.Errorf("interactive terminal required") + return -1, errors.New("interactive terminal required") } items := make([]list.Item, len(options)) @@ -37,16 +38,16 @@ func Select(label string, options []string) (int, error) { result, ok := finalModel.(selectModel) if !ok { - return -1, fmt.Errorf("failed to read selection") + return -1, errors.New("failed to read selection") } if result.canceled { - return -1, fmt.Errorf("selection canceled") + return -1, errors.New("selection canceled") } if result.choice < 0 { - return -1, fmt.Errorf("selection required") + return -1, errors.New("selection required") } if result.choice >= len(options) { - return -1, fmt.Errorf("selection out of range") + return -1, errors.New("selection out of range") } clearScreenAndShowSelection(label, options[result.choice]) diff --git a/cli/internal/types/config.go b/cli/internal/types/config.go index 7debc3c6a7..e0186418a4 100644 --- a/cli/internal/types/config.go +++ b/cli/internal/types/config.go @@ -1,7 +1,7 @@ package types import ( - "fmt" + "errors" "maps" "strings" ) @@ -81,23 +81,21 @@ type Config struct { // ServerURL is the base URL of the Arcane server (e.g., http://localhost:3552) ServerURL string `yaml:"server_url" mapstructure:"server_url"` // APIKey is the API key for authentication (sent as X-API-KEY) - APIKey string `yaml:"api_key,omitempty" mapstructure:"api_key"` //nolint:gosec // persisted config schema requires this field name + APIKey string `yaml:"api_key,omitempty" mapstructure:"api_key"` // JWTToken is the JWT access token for authentication (sent as Authorization: Bearer) - JWTToken string `yaml:"jwt_token,omitempty" mapstructure:"jwt_token"` //nolint:gosec // persisted config schema requires this field name + JWTToken string `yaml:"jwt_token,omitempty" mapstructure:"jwt_token"` // RefreshToken is the refresh token for obtaining new access tokens - RefreshToken string `yaml:"refresh_token,omitempty" mapstructure:"refresh_token"` //nolint:gosec // persisted config schema requires this field name + RefreshToken string `yaml:"refresh_token,omitempty" mapstructure:"refresh_token"` // DefaultEnvironment is the default environment ID to use DefaultEnvironment string `yaml:"default_environment,omitempty" mapstructure:"default_environment"` + // FederatedAudience is the default token audience for `arcane-cli auth federated` + FederatedAudience string `yaml:"federated_audience,omitempty" mapstructure:"federated_audience"` // LogLevel is the logging level (debug, info, warn, error, fatal, panic) LogLevel string `yaml:"log_level,omitempty" mapstructure:"log_level"` // CLIUpdateChannel controls which channel self-update uses (stable or next). CLIUpdateChannel string `yaml:"cli_update_channel,omitempty" mapstructure:"cli_update_channel"` // Pagination contains global and per-resource pagination configuration. Pagination PaginationConfig `yaml:"pagination,omitempty" mapstructure:"pagination"` - // DefaultLimit is a legacy global default list limit for paginated resources. - DefaultLimit int `yaml:"default_limit,omitempty" mapstructure:"default_limit"` - // ResourceLimits is a legacy map of per-resource list limits. - ResourceLimits map[string]int `yaml:"resource_limits,omitempty" mapstructure:"resource_limits"` } // HasAuth returns true if either an API key or JWT token is configured. @@ -109,7 +107,7 @@ func (c *Config) HasAuth() bool { // This is useful for commands like `auth login` that do not require prior authentication. func (c *Config) ValidateServerURL() error { if c.ServerURL == "" { - return fmt.Errorf("server_url is not configured. Run: arcane config set --server-url ") + return errors.New("server_url is not configured. Run: arcane config set --server-url ") } return nil } @@ -122,7 +120,7 @@ func (c *Config) Validate() error { return err } if !c.HasAuth() { - return fmt.Errorf("authentication is not configured. Run: arcane config set --api-key OR arcane auth login") + return errors.New("authentication is not configured. Run: arcane config set --api-key OR arcane auth login") } return nil } @@ -140,10 +138,6 @@ func (c *Config) Clone() *Config { return nil } out := *c - if c.ResourceLimits != nil { - out.ResourceLimits = make(map[string]int, len(c.ResourceLimits)) - maps.Copy(out.ResourceLimits, c.ResourceLimits) - } if c.Pagination.Resources != nil { out.Pagination.Resources = make(map[string]PaginationResourceConfig, len(c.Pagination.Resources)) maps.Copy(out.Pagination.Resources, c.Pagination.Resources) @@ -151,7 +145,7 @@ func (c *Config) Clone() *Config { return &out } -// LimitFor returns the configured limit for a resource, falling back to DefaultLimit. +// LimitFor returns the configured limit for a resource, falling back to the global default. func (c *Config) LimitFor(resource string) int { if c == nil { return 0 @@ -165,16 +159,6 @@ func (c *Config) LimitFor(resource string) int { if c.Pagination.Default.Limit > 0 { return c.Pagination.Default.Limit } - - // Backward-compatibility with legacy keys. - if resource != "" && c.ResourceLimits != nil { - if v, ok := c.ResourceLimits[resource]; ok && v > 0 { - return v - } - } - if c.DefaultLimit > 0 { - return c.DefaultLimit - } return 0 } @@ -184,8 +168,6 @@ func (c *Config) SetDefaultLimit(limit int) { return } c.Pagination.Default.Limit = limit - // Keep legacy field in sync for compatibility. - c.DefaultLimit = limit } // SetResourceLimit configures per-resource pagination defaults. @@ -200,15 +182,9 @@ func (c *Config) SetResourceLimit(resource string, limit int) { if c.Pagination.Resources == nil { c.Pagination.Resources = make(map[string]PaginationResourceConfig) } - if c.ResourceLimits == nil { - c.ResourceLimits = make(map[string]int) - } if limit <= 0 { delete(c.Pagination.Resources, resource) - delete(c.ResourceLimits, resource) return } c.Pagination.Resources[resource] = PaginationResourceConfig{Limit: limit} - // Keep legacy field in sync for compatibility. - c.ResourceLimits[resource] = limit } diff --git a/cli/internal/types/endpoints.go b/cli/internal/types/endpoints.go index 91d8f1418d..350771a214 100644 --- a/cli/internal/types/endpoints.go +++ b/cli/internal/types/endpoints.go @@ -9,10 +9,11 @@ type ArcaneApiEndpoints struct { VersionEndpoint string // Authentication - AuthLogoutEndpoint string - AuthMeEndpoint string - AuthPasswordEndpoint string - AuthRefreshEndpoint string + AuthLogoutEndpoint string + AuthMeEndpoint string + AuthPasswordEndpoint string + AuthRefreshEndpoint string + AuthFederatedEndpoint string // OIDC OIDCDeviceCodeEndpoint string @@ -24,8 +25,18 @@ type ArcaneApiEndpoints struct { ApiKeyEndpoint string // Users - UsersEndpoint string - UserEndpoint string + UsersEndpoint string + UserEndpoint string + UserRoleAssignmentsEndpoint string + + // Roles (RBAC) + RolesEndpoint string + RoleEndpoint string + RolesAvailablePermissionsEndpoint string + + // OIDC role mappings + OidcRoleMappingsEndpoint string + OidcRoleMappingEndpoint string // Environments EnvironmentsEndpoint string @@ -106,8 +117,6 @@ type ArcaneApiEndpoints struct { SettingsPublicEndpoint string // Notifications - NotificationsAppriseEndpoint string - NotificationsAppriseTestEndpoint string NotificationsSettingsEndpoint string NotificationSettingsProviderEndpoint string NotificationsTestProviderEndpoint string @@ -136,14 +145,12 @@ type ArcaneApiEndpoints struct { TemplateFetchEndpoint string // Dashboard - DashboardActionItemsEndpoint string + DashboardEndpoint string // Assets AppImagesFaviconEndpoint string AppImagesLogoEndpoint string AppImagesProfileEndpoint string - FontsMonoEndpoint string - FontsSansEndpoint string // Customization CustomizeCategoriesEndpoint string @@ -172,10 +179,11 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut VersionEndpoint: "/api/version", // Authentication - AuthLogoutEndpoint: "/api/auth/logout", - AuthMeEndpoint: "/api/auth/me", - AuthPasswordEndpoint: "/api/auth/password", - AuthRefreshEndpoint: "/api/auth/refresh", + AuthLogoutEndpoint: "/api/auth/logout", + AuthMeEndpoint: "/api/auth/me", + AuthPasswordEndpoint: "/api/auth/password", + AuthRefreshEndpoint: "/api/auth/refresh", + AuthFederatedEndpoint: "/api/auth/federated/token", // OIDC OIDCDeviceCodeEndpoint: "/api/oidc/device/code", @@ -187,8 +195,18 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut ApiKeyEndpoint: "/api/api-keys/%s", // Users - UsersEndpoint: "/api/users", - UserEndpoint: "/api/users/%s", + UsersEndpoint: "/api/users", + UserEndpoint: "/api/users/%s", + UserRoleAssignmentsEndpoint: "/api/users/%s/role-assignments", + + // Roles (RBAC) + RolesEndpoint: "/api/roles", + RoleEndpoint: "/api/roles/%s", + RolesAvailablePermissionsEndpoint: "/api/roles/available-permissions", + + // OIDC role mappings + OidcRoleMappingsEndpoint: "/api/oidc/role-mappings", + OidcRoleMappingEndpoint: "/api/oidc/role-mappings/%s", // Environments EnvironmentsEndpoint: "/api/environments", @@ -269,8 +287,6 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut SettingsPublicEndpoint: "/api/environments/%s/settings/public", // Notifications - NotificationsAppriseEndpoint: "/api/environments/%s/notifications/apprise", - NotificationsAppriseTestEndpoint: "/api/environments/%s/notifications/apprise/test", NotificationsSettingsEndpoint: "/api/environments/%s/notifications/settings", NotificationSettingsProviderEndpoint: "/api/environments/%s/notifications/settings/%s", NotificationsTestProviderEndpoint: "/api/environments/%s/notifications/test/%s", @@ -299,14 +315,12 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut TemplateFetchEndpoint: "/api/templates/fetch", // Dashboard - DashboardActionItemsEndpoint: "/api/environments/%s/dashboard/action-items", + DashboardEndpoint: "/api/environments/%s/dashboard", // Assets AppImagesFaviconEndpoint: "/api/app-images/favicon", AppImagesLogoEndpoint: "/api/app-images/logo", AppImagesProfileEndpoint: "/api/app-images/profile", - FontsMonoEndpoint: "/api/fonts/mono", - FontsSansEndpoint: "/api/fonts/sans", // Customization CustomizeCategoriesEndpoint: "/api/customize/categories", @@ -330,25 +344,49 @@ var Endpoints = ArcaneApiEndpoints{ //nolint:gosec // static endpoint paths; aut } // Auth endpoints -func (e ArcaneApiEndpoints) AuthLogout() string { return e.AuthLogoutEndpoint } -func (e ArcaneApiEndpoints) AuthMe() string { return e.AuthMeEndpoint } -func (e ArcaneApiEndpoints) AuthPassword() string { return e.AuthPasswordEndpoint } -func (e ArcaneApiEndpoints) AuthRefresh() string { return e.AuthRefreshEndpoint } + +func (e ArcaneApiEndpoints) AuthLogout() string { return e.AuthLogoutEndpoint } +func (e ArcaneApiEndpoints) AuthMe() string { return e.AuthMeEndpoint } +func (e ArcaneApiEndpoints) AuthPassword() string { return e.AuthPasswordEndpoint } +func (e ArcaneApiEndpoints) AuthRefresh() string { return e.AuthRefreshEndpoint } +func (e ArcaneApiEndpoints) AuthFederated() string { return e.AuthFederatedEndpoint } // OIDC endpoints + func (e ArcaneApiEndpoints) OIDCDeviceCode() string { return e.OIDCDeviceCodeEndpoint } func (e ArcaneApiEndpoints) OIDCDeviceToken() string { return e.OIDCDeviceTokenEndpoint } func (e ArcaneApiEndpoints) OIDCStatus() string { return e.OIDCStatusEndpoint } // API Key endpoints + func (e ArcaneApiEndpoints) ApiKeys() string { return e.ApiKeysEndpoint } func (e ArcaneApiEndpoints) ApiKey(id string) string { return fmt.Sprintf(e.ApiKeyEndpoint, id) } // User endpoints + func (e ArcaneApiEndpoints) Users() string { return e.UsersEndpoint } func (e ArcaneApiEndpoints) User(id string) string { return fmt.Sprintf(e.UserEndpoint, id) } +func (e ArcaneApiEndpoints) UserRoleAssignments(userID string) string { + return fmt.Sprintf(e.UserRoleAssignmentsEndpoint, userID) +} + +// Role (RBAC) endpoints + +func (e ArcaneApiEndpoints) Roles() string { return e.RolesEndpoint } +func (e ArcaneApiEndpoints) Role(id string) string { return fmt.Sprintf(e.RoleEndpoint, id) } +func (e ArcaneApiEndpoints) RolesAvailablePermissions() string { + return e.RolesAvailablePermissionsEndpoint +} + +// OIDC role mapping endpoints + +func (e ArcaneApiEndpoints) OidcRoleMappings() string { return e.OidcRoleMappingsEndpoint } +func (e ArcaneApiEndpoints) OidcRoleMapping(id string) string { + return fmt.Sprintf(e.OidcRoleMappingEndpoint, id) +} // Environment endpoints + func (e ArcaneApiEndpoints) Environments() string { return e.EnvironmentsEndpoint } func (e ArcaneApiEndpoints) Environment(id string) string { @@ -364,6 +402,7 @@ func (e ArcaneApiEndpoints) EnvironmentVersion(envID string) string { } // Container endpoints + func (e ArcaneApiEndpoints) Containers(envID string) string { return fmt.Sprintf(e.ContainersEndpoint, envID) } @@ -397,6 +436,7 @@ func (e ArcaneApiEndpoints) ContainersCounts(envID string) string { } // Image endpoints + func (e ArcaneApiEndpoints) Images(envID string) string { return fmt.Sprintf(e.ImagesEndpoint, envID) } func (e ArcaneApiEndpoints) Image(envID, imageID string) string { @@ -420,6 +460,7 @@ func (e ArcaneApiEndpoints) ImagesUpload(envID string) string { } // Image Update endpoints + func (e ArcaneApiEndpoints) ImageUpdatesCheck(envID string) string { return fmt.Sprintf(e.ImageUpdatesCheckEndpoint, envID) } @@ -437,6 +478,7 @@ func (e ArcaneApiEndpoints) ImageUpdatesSummary(envID string) string { } // Network endpoints + func (e ArcaneApiEndpoints) Networks(envID string) string { return fmt.Sprintf(e.NetworksEndpoint, envID) } @@ -454,6 +496,7 @@ func (e ArcaneApiEndpoints) NetworksPrune(envID string) string { } // Volume endpoints + func (e ArcaneApiEndpoints) Volumes(envID string) string { return fmt.Sprintf(e.VolumesEndpoint, envID) } @@ -479,6 +522,7 @@ func (e ArcaneApiEndpoints) VolumeUsage(envID, volumeName string) string { } // Project endpoints + func (e ArcaneApiEndpoints) Projects(envID string) string { return fmt.Sprintf(e.ProjectsEndpoint, envID) } @@ -520,6 +564,7 @@ func (e ArcaneApiEndpoints) ProjectIncludes(envID, projectID string) string { } // System endpoints + func (e ArcaneApiEndpoints) SystemPrune(envID string) string { return fmt.Sprintf(e.SystemPruneEndpoint, envID) } @@ -553,6 +598,7 @@ func (e ArcaneApiEndpoints) SystemUpgradeCheck(envID string) string { } // Updater endpoints + func (e ArcaneApiEndpoints) UpdaterStatus(envID string) string { return fmt.Sprintf(e.UpdaterStatusEndpoint, envID) } @@ -566,11 +612,13 @@ func (e ArcaneApiEndpoints) UpdaterHistory(envID string) string { } // Job schedule endpoints + func (e ArcaneApiEndpoints) JobSchedules(envID string) string { return fmt.Sprintf(e.JobSchedulesEndpoint, envID) } // Settings endpoints + func (e ArcaneApiEndpoints) Settings(envID string) string { return fmt.Sprintf(e.SettingsEndpoint, envID) } @@ -580,13 +628,6 @@ func (e ArcaneApiEndpoints) SettingsPublic(envID string) string { } // Notification endpoints -func (e ArcaneApiEndpoints) NotificationsApprise(envID string) string { - return fmt.Sprintf(e.NotificationsAppriseEndpoint, envID) -} - -func (e ArcaneApiEndpoints) NotificationsAppriseTest(envID string) string { - return fmt.Sprintf(e.NotificationsAppriseTestEndpoint, envID) -} func (e ArcaneApiEndpoints) NotificationsSettings(envID string) string { return fmt.Sprintf(e.NotificationsSettingsEndpoint, envID) @@ -601,6 +642,7 @@ func (e ArcaneApiEndpoints) NotificationsTestProvider(envID, provider string) st } // Container Registry endpoints + func (e ArcaneApiEndpoints) ContainerRegistries() string { return e.ContainerRegistriesEndpoint } func (e ArcaneApiEndpoints) ContainerRegistry(id string) string { @@ -612,6 +654,7 @@ func (e ArcaneApiEndpoints) ContainerRegistryTest(id string) string { } // Event endpoints + func (e ArcaneApiEndpoints) Events() string { return e.EventsEndpoint } func (e ArcaneApiEndpoints) Event(id string) string { return fmt.Sprintf(e.EventEndpoint, id) } func (e ArcaneApiEndpoints) EventsEnvironment(envID string) string { @@ -619,6 +662,7 @@ func (e ArcaneApiEndpoints) EventsEnvironment(envID string) string { } // Template endpoints + func (e ArcaneApiEndpoints) Templates() string { return e.TemplatesEndpoint } func (e ArcaneApiEndpoints) Template(id string) string { return fmt.Sprintf(e.TemplateEndpoint, id) } func (e ArcaneApiEndpoints) TemplatesAll() string { return e.TemplatesAllEndpoint } @@ -637,11 +681,13 @@ func (e ArcaneApiEndpoints) TemplateDownload(id string) string { func (e ArcaneApiEndpoints) TemplateFetch() string { return e.TemplateFetchEndpoint } // Dashboard endpoints -func (e ArcaneApiEndpoints) DashboardActionItems(envID string) string { - return fmt.Sprintf(e.DashboardActionItemsEndpoint, envID) + +func (e ArcaneApiEndpoints) Dashboard(envID string) string { + return fmt.Sprintf(e.DashboardEndpoint, envID) } // GitOps Sync endpoints + func (e ArcaneApiEndpoints) GitOpsSyncs(envID string) string { return fmt.Sprintf(e.GitOpsSyncsEndpoint, envID) } @@ -662,6 +708,7 @@ func (e ArcaneApiEndpoints) GitOpsSyncsImport(envID string) string { } // Git Repository endpoints + func (e ArcaneApiEndpoints) GitRepositories() string { return e.GitRepositoriesEndpoint } func (e ArcaneApiEndpoints) GitRepository(id string) string { return fmt.Sprintf(e.GitRepositoryEndpoint, id) diff --git a/cli/pkg/admin/apikeys/cmd.go b/cli/pkg/admin/apikeys/cmd.go index dab8200e6c..3d44fc1519 100644 --- a/cli/pkg/admin/apikeys/cmd.go +++ b/cli/pkg/admin/apikeys/cmd.go @@ -1,11 +1,12 @@ package apikeys import ( - "encoding/json" + "errors" "fmt" + "strconv" + "strings" "time" - "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" "github.com/getarcaneapp/arcane/cli/internal/output" "github.com/getarcaneapp/arcane/cli/internal/types" @@ -21,12 +22,48 @@ var ( jsonOutput bool ) +var ( + apikeyCreatePermissions []string +) + var ( apikeyUpdateName string apikeyUpdateDescription string apikeyUpdateExpiresAt string + apikeyUpdatePermissions []string ) +// parsePermissionGrantsInternal turns `--permission` tokens into the wire-format +// grant list. Each token is either `resource:action` (global grant) or +// `resource:action:envId` (env-scoped grant). Anything else is an error so the +// user catches typos before the server rejects them. +func parsePermissionGrantsInternal(tokens []string) ([]apikey.PermissionGrant, error) { + out := make([]apikey.PermissionGrant, 0, len(tokens)) + for _, raw := range tokens { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + parts := strings.Split(token, ":") + switch len(parts) { + case 2: + out = append(out, apikey.PermissionGrant{Permission: token}) + case 3: + env := parts[2] + if parts[0] == "" || parts[1] == "" || env == "" { + return nil, fmt.Errorf("invalid --permission value %q (expected `resource:action[:envId]`)", raw) + } + out = append(out, apikey.PermissionGrant{ + Permission: parts[0] + ":" + parts[1], + EnvironmentID: &env, + }) + default: + return nil, fmt.Errorf("invalid --permission value %q (expected `resource:action[:envId]`)", raw) + } + } + return out, nil +} + // ApiKeysCmd is the parent command for API key operations var ApiKeysCmd = &cobra.Command{ Use: "api-keys", @@ -40,7 +77,7 @@ var listCmd = &cobra.Command{ Short: "List API keys", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -58,20 +95,15 @@ var listCmd = &cobra.Command{ defer func() { _ = resp.Body.Close() }() var result base.Paginated[apikey.ApiKey] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list API keys: %w", err) } if jsonOutput { - resultBytes, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result) } - headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED", "LAST USED"} + headers := []string{"ID", "NAME", "DESCRIPTION", "PERMISSIONS", "CREATED", "LAST USED"} rows := make([][]string, len(result.Data)) for i, key := range result.Data { description := "" @@ -86,6 +118,7 @@ var listCmd = &cobra.Command{ key.ID, key.Name, description, + strconv.Itoa(len(key.Permissions)), key.CreatedAt.Format("2006-01-02 15:04"), lastUsed, } @@ -105,51 +138,46 @@ var createCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { description, _ := cmd.Flags().GetString("description") - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } + grants, err := parsePermissionGrantsInternal(apikeyCreatePermissions) + if err != nil { + return err + } + if len(grants) == 0 { + return errors.New("at least one --permission is required (e.g. --permission containers:list)") + } createReq := apikey.CreateApiKey{ - Name: args[0], + Name: args[0], + Permissions: grants, } if description != "" { createReq.Description = &description } - // TODO: Parse expiresAt string to time.Time if needed - // if expiresAt != "" { - // parsedTime, err := time.Parse(time.RFC3339, expiresAt) - // if err == nil { - // createReq.ExpiresAt = &parsedTime - // } - // } - - reqBody, err := json.Marshal(createReq) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) + if expiresAtRaw, _ := cmd.Flags().GetString("expires-at"); expiresAtRaw != "" { + parsed, err := time.Parse(time.RFC3339, expiresAtRaw) + if err != nil { + return fmt.Errorf("invalid --expires-at format (use RFC3339): %w", err) + } + createReq.ExpiresAt = &parsed } - resp, err := c.Post(cmd.Context(), types.Endpoints.ApiKeys(), reqBody) + resp, err := c.Post(cmd.Context(), types.Endpoints.ApiKeys(), createReq) if err != nil { return fmt.Errorf("failed to create API key: %w", err) } defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to create API key: %w", err) - } var result base.ApiResponse[apikey.ApiKeyCreatedDto] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to create API key: %w", err) } if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result.Data) } output.Success("API key created successfully") @@ -181,7 +209,7 @@ var deleteCmd = &cobra.Command{ } } - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -197,15 +225,10 @@ var deleteCmd = &cobra.Command{ if jsonOutput { var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to delete API key: %w", err) } - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result.Data) } output.Success("API key deleted successfully") @@ -219,7 +242,7 @@ var getCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -231,17 +254,12 @@ var getCmd = &cobra.Command{ defer func() { _ = resp.Body.Close() }() var result base.ApiResponse[apikey.ApiKey] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to get API key: %w", err) } if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return cmdutil.PrintJSON(result.Data) } output.Header("API Key Details") @@ -258,6 +276,18 @@ var getCmd = &cobra.Command{ if result.Data.ExpiresAt != nil { output.KeyValue("Expires", result.Data.ExpiresAt.Format("2006-01-02 15:04")) } + output.KeyValue("Permissions", strconv.Itoa(len(result.Data.Permissions))) + if len(result.Data.Permissions) > 0 { + rows := make([][]string, len(result.Data.Permissions)) + for i, g := range result.Data.Permissions { + scope := "global" + if g.EnvironmentID != nil { + scope = *g.EnvironmentID + } + rows[i] = []string{g.Permission, scope} + } + output.Table([]string{"PERMISSION", "SCOPE"}, rows) + } return nil }, } @@ -268,7 +298,7 @@ var updateCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() + c, err := cmdutil.ClientFromCommand(cmd) if err != nil { return err } @@ -287,6 +317,15 @@ var updateCmd = &cobra.Command{ } req.ExpiresAt = &parsedTime } + if cmd.Flags().Changed("permission") { + grants, err := parsePermissionGrantsInternal(apikeyUpdatePermissions) + if err != nil { + return err + } + // Allow `--permission ""` once to clear all grants. Otherwise + // the parsed slice replaces the key's permission set entirely. + req.Permissions = grants + } resp, err := c.Put(cmd.Context(), types.Endpoints.ApiKey(args[0]), req) if err != nil { @@ -299,12 +338,10 @@ var updateCmd = &cobra.Command{ if jsonOutput { var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { - if resultBytes, err := json.MarshalIndent(result.Data, "", " "); err == nil { - fmt.Println(string(resultBytes)) - } + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to update API key: %w", err) } - return nil + return cmdutil.PrintJSON(result.Data) } output.Success("API key updated successfully") @@ -326,8 +363,11 @@ func init() { // Create command flags createCmd.Flags().StringP("description", "d", "", "Description for the API key") - createCmd.Flags().String("expires-at", "", "Expiration date (ISO 8601 format)") + createCmd.Flags().String("expires-at", "", "Expiration date (RFC3339 format)") + createCmd.Flags().StringArrayVarP(&apikeyCreatePermissions, "permission", "p", nil, + "Permission to grant as `resource:action[:envId]` (repeatable, required). Omit envId for a global grant. Cannot exceed your own permissions.") createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + _ = createCmd.MarkFlagRequired("permission") // Get command flags getCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") @@ -336,6 +376,8 @@ func init() { updateCmd.Flags().StringVar(&apikeyUpdateName, "name", "", "API key name") updateCmd.Flags().StringVarP(&apikeyUpdateDescription, "description", "d", "", "API key description") updateCmd.Flags().StringVar(&apikeyUpdateExpiresAt, "expires-at", "", "Expiration date (RFC3339 format)") + updateCmd.Flags().StringArrayVarP(&apikeyUpdatePermissions, "permission", "p", nil, + "Replace the key's permission grants. Use `resource:action[:envId]` (repeatable). Pass --permission \"\" once to clear all grants.") updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") // Delete command flags diff --git a/cli/pkg/admin/cmd.go b/cli/pkg/admin/cmd.go index c671a04518..43339b73ec 100644 --- a/cli/pkg/admin/cmd.go +++ b/cli/pkg/admin/cmd.go @@ -4,6 +4,8 @@ import ( "github.com/getarcaneapp/arcane/cli/pkg/admin/apikeys" "github.com/getarcaneapp/arcane/cli/pkg/admin/events" "github.com/getarcaneapp/arcane/cli/pkg/admin/notifications" + "github.com/getarcaneapp/arcane/cli/pkg/admin/oidcmappings" + "github.com/getarcaneapp/arcane/cli/pkg/admin/roles" "github.com/getarcaneapp/arcane/cli/pkg/admin/users" "github.com/spf13/cobra" ) @@ -17,6 +19,8 @@ var AdminCmd = &cobra.Command{ func init() { AdminCmd.AddCommand(users.UsersCmd) + AdminCmd.AddCommand(roles.RolesCmd) + AdminCmd.AddCommand(oidcmappings.OidcMappingsCmd) AdminCmd.AddCommand(apikeys.ApiKeysCmd) AdminCmd.AddCommand(events.EventsCmd) AdminCmd.AddCommand(notifications.NotificationsCmd) diff --git a/cli/pkg/admin/notifications/cmd.go b/cli/pkg/admin/notifications/cmd.go index 78f8df9c5b..5d12909493 100644 --- a/cli/pkg/admin/notifications/cmd.go +++ b/cli/pkg/admin/notifications/cmd.go @@ -3,6 +3,7 @@ package notifications import ( "encoding/json" "fmt" + "strconv" "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" @@ -25,56 +26,11 @@ var NotificationsCmd = &cobra.Command{ Short: "Manage notifications", } -var appriseCmd = &cobra.Command{ - Use: "apprise", - Short: "Manage Apprise configuration", -} - var settingsCmd = &cobra.Command{ Use: "settings", Short: "Manage notification settings", } -var appriseGetCmd = &cobra.Command{ - Use: "get", - Short: "Get Apprise configuration", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.NotificationsApprise(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get apprise config: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[notification.AppriseResponse] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Header("Apprise Configuration") - output.KeyValue("ID", fmt.Sprintf("%d", result.Data.ID)) - output.KeyValue("API URL", result.Data.APIURL) - output.KeyValue("Enabled", fmt.Sprintf("%t", result.Data.Enabled)) - output.KeyValue("Image Update Tag", result.Data.ImageUpdateTag) - output.KeyValue("Container Update Tag", result.Data.ContainerUpdateTag) - return nil - }, -} - var settingsGetCmd = &cobra.Command{ Use: "get", Short: "Get notification settings", @@ -109,9 +65,9 @@ var settingsGetCmd = &cobra.Command{ rows := make([][]string, len(result)) for i, setting := range result { rows[i] = []string{ - fmt.Sprintf("%d", setting.ID), + strconv.FormatUint(uint64(setting.ID), 10), string(setting.Provider), - fmt.Sprintf("%t", setting.Enabled), + strconv.FormatBool(setting.Enabled), } } @@ -121,40 +77,6 @@ var settingsGetCmd = &cobra.Command{ }, } -var appriseTestCmd = &cobra.Command{ - Use: "test", - Short: "Test Apprise notification", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.NotificationsAppriseTest(c.EnvID()), nil) - if err != nil { - return fmt.Errorf("failed to test apprise: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("apprise test failed: %w", err) - } - - if jsonOutput { - var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { - if resultBytes, err := json.MarshalIndent(result.Data, "", " "); err == nil { - fmt.Println(string(resultBytes)) - } - } - return nil - } - - output.Success("Apprise notification test successful") - return nil - }, -} - var settingsDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete notification provider settings", @@ -227,18 +149,12 @@ var testProviderCmd = &cobra.Command{ } func init() { - NotificationsCmd.AddCommand(appriseCmd) NotificationsCmd.AddCommand(settingsCmd) NotificationsCmd.AddCommand(testProviderCmd) - appriseCmd.AddCommand(appriseGetCmd) - appriseCmd.AddCommand(appriseTestCmd) - settingsCmd.AddCommand(settingsGetCmd) settingsCmd.AddCommand(settingsDeleteCmd) - appriseGetCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") - appriseTestCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") settingsGetCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") settingsDeleteCmd.Flags().BoolVarP(¬ifForceFlag, "force", "f", false, "Force deletion without confirmation") settingsDeleteCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") diff --git a/cli/pkg/admin/oidcmappings/cmd.go b/cli/pkg/admin/oidcmappings/cmd.go new file mode 100644 index 0000000000..b3e699f061 --- /dev/null +++ b/cli/pkg/admin/oidcmappings/cmd.go @@ -0,0 +1,255 @@ +// Package oidcmappings provides the `arcane admin oidc-mappings` command tree. +// Mappings convert OIDC group/claim values into role assignments on every +// login — they're the SSO-driven counterpart to manual role assignments. +package oidcmappings + +import ( + "fmt" + + "github.com/getarcaneapp/arcane/cli/internal/cmdutil" + "github.com/getarcaneapp/arcane/cli/internal/output" + "github.com/getarcaneapp/arcane/cli/internal/types" + "github.com/getarcaneapp/arcane/types/base" + roletypes "github.com/getarcaneapp/arcane/types/role" + "github.com/spf13/cobra" +) + +var ( + forceFlag bool + jsonOutput bool +) + +var ( + createClaim string + createRoleID string + createEnvironment string +) + +var ( + updateClaim string + updateRoleID string + updateEnvironment string +) + +// OidcMappingsCmd is the parent command for OIDC role mapping operations. +var OidcMappingsCmd = &cobra.Command{ + Use: "oidc-mappings", + Aliases: []string{"oidc-mapping", "oidc"}, + Short: "Manage OIDC group → role mappings", + Long: "Manage OIDC group → role mappings. On every OIDC login, Arcane " + + "looks up the user's groups claim and applies the matching mappings " + + "as source='oidc' role assignments. The groups claim itself is " + + "configured via the oidcGroupsClaim setting (default `groups`).", +} + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List OIDC role mappings", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.OidcRoleMappings()) + if err != nil { + return fmt.Errorf("failed to list OIDC mappings: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[[]roletypes.OidcRoleMapping] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list OIDC mappings: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + if len(result.Data) == 0 { + output.Info("No OIDC role mappings configured") + return nil + } + + headers := []string{"ID", "CLAIM VALUE", "ROLE", "SCOPE"} + rows := make([][]string, len(result.Data)) + for i, m := range result.Data { + scope := "global" + if m.EnvironmentID != nil { + scope = *m.EnvironmentID + } + rows[i] = []string{m.ID, m.ClaimValue, m.RoleID, scope} + } + output.Table(headers, rows) + return nil + }, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create an OIDC role mapping", + Example: ` arcane admin oidc-mappings create --claim docker-admins --role role_admin`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + req := roletypes.CreateOidcRoleMapping{ + ClaimValue: createClaim, + RoleID: createRoleID, + } + if cmd.Flags().Changed("environment") && createEnvironment != "" { + req.EnvironmentID = new(createEnvironment) + } + + resp, err := c.Post(cmd.Context(), types.Endpoints.OidcRoleMappings(), req) + if err != nil { + return fmt.Errorf("failed to create OIDC mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.OidcRoleMapping] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to create OIDC mapping: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + output.Success("OIDC mapping created") + output.KeyValue("ID", result.Data.ID) + output.KeyValue("Claim value", result.Data.ClaimValue) + output.KeyValue("Role", result.Data.RoleID) + scope := "global" + if result.Data.EnvironmentID != nil { + scope = *result.Data.EnvironmentID + } + output.KeyValue("Scope", scope) + return nil + }, +} + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an OIDC role mapping", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + // The PUT contract requires every field — fetch current and overlay. + current, err := fetchMappingInternal(cmd, args[0]) + if err != nil { + return err + } + + req := roletypes.UpdateOidcRoleMapping{ + ClaimValue: current.ClaimValue, + RoleID: current.RoleID, + EnvironmentID: current.EnvironmentID, + } + if cmd.Flags().Changed("claim") { + req.ClaimValue = updateClaim + } + if cmd.Flags().Changed("role") { + req.RoleID = updateRoleID + } + if cmd.Flags().Changed("environment") { + if updateEnvironment == "" { + // Empty value flips an env-scoped mapping back to global. + req.EnvironmentID = nil + } else { + req.EnvironmentID = new(updateEnvironment) + } + } + + resp, err := c.Put(cmd.Context(), types.Endpoints.OidcRoleMapping(args[0]), req) + if err != nil { + return fmt.Errorf("failed to update OIDC mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to update OIDC mapping: %w", err) + } + output.Success("OIDC mapping updated") + return nil + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm", "remove"}, + Short: "Delete an OIDC role mapping", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if !forceFlag { + confirmed, err := cmdutil.Confirm(cmd, fmt.Sprintf("Delete OIDC mapping %s?", args[0])) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled") + return nil + } + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Delete(cmd.Context(), types.Endpoints.OidcRoleMapping(args[0])) + if err != nil { + return fmt.Errorf("failed to delete OIDC mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to delete OIDC mapping: %w", err) + } + output.Success("OIDC mapping deleted") + return nil + }, +} + +func fetchMappingInternal(cmd *cobra.Command, id string) (*roletypes.OidcRoleMapping, error) { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return nil, err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.OidcRoleMapping(id)) + if err != nil { + return nil, fmt.Errorf("failed to load current mapping: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.OidcRoleMapping] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("failed to load current mapping: %w", err) + } + return &result.Data, nil +} + +func init() { + OidcMappingsCmd.AddCommand(listCmd) + OidcMappingsCmd.AddCommand(createCmd) + OidcMappingsCmd.AddCommand(updateCmd) + OidcMappingsCmd.AddCommand(deleteCmd) + + listCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + createCmd.Flags().StringVar(&createClaim, "claim", "", "OIDC claim value (required)") + createCmd.Flags().StringVar(&createRoleID, "role", "", "Role ID to grant when the claim matches (required)") + createCmd.Flags().StringVar(&createEnvironment, "environment", "", "Scope the grant to one environment (omit for global)") + createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + _ = createCmd.MarkFlagRequired("claim") + _ = createCmd.MarkFlagRequired("role") + + updateCmd.Flags().StringVar(&updateClaim, "claim", "", "New claim value") + updateCmd.Flags().StringVar(&updateRoleID, "role", "", "New role ID") + updateCmd.Flags().StringVar(&updateEnvironment, "environment", "", "New environment scope (pass empty to make global)") + updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + deleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Force deletion without confirmation") +} diff --git a/cli/pkg/admin/roles/cmd.go b/cli/pkg/admin/roles/cmd.go new file mode 100644 index 0000000000..0336da3507 --- /dev/null +++ b/cli/pkg/admin/roles/cmd.go @@ -0,0 +1,480 @@ +// Package roles provides the `arcane admin roles` command tree for RBAC. +// Roles are named permission sets that can be granted to users (globally or +// per-environment) and to API keys. Four built-in roles (Admin, Editor, +// Deployer, Viewer) ship with Arcane and cannot be modified; everything else +// is a custom role created via this command. +package roles + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/getarcaneapp/arcane/cli/internal/cmdutil" + "github.com/getarcaneapp/arcane/cli/internal/output" + "github.com/getarcaneapp/arcane/cli/internal/types" + "github.com/getarcaneapp/arcane/types/base" + roletypes "github.com/getarcaneapp/arcane/types/role" + "github.com/spf13/cobra" +) + +var ( + limitFlag int + startFlag int + forceFlag bool + jsonOutput bool +) + +var ( + roleCreateName string + roleCreateDescription string + roleCreatePermissions []string +) + +var ( + roleUpdateName string + roleUpdateDescription string + roleUpdatePermissions []string +) + +// `assign` flags. Each --role argument is parsed as `[:]`. +// Omitting the env id gives a global assignment. +var ( + assignRoles []string +) + +// RolesCmd is the parent command for role operations. +var RolesCmd = &cobra.Command{ + Use: "roles", + Aliases: []string{"role"}, + Short: "Manage roles and per-user role assignments", +} + +// ---------- Role CRUD ---------- + +var listCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List roles", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + path := types.Endpoints.Roles() + path, err = cmdutil.ApplyPaginationParams(cmd, path, "roles", "limit", limitFlag, 20, "start", startFlag) + if err != nil { + return fmt.Errorf("failed to build pagination query: %w", err) + } + + resp, err := c.Get(cmd.Context(), path) + if err != nil { + return fmt.Errorf("failed to list roles: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.Paginated[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list roles: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result) + } + + headers := []string{"ID", "NAME", "TYPE", "USERS", "PERMISSIONS"} + rows := make([][]string, len(result.Data)) + for i, r := range result.Data { + roleType := "custom" + if r.BuiltIn { + roleType = "built-in" + } + rows[i] = []string{ + r.ID, + r.Name, + roleType, + strconv.Itoa(r.AssignedUserCount), + strconv.Itoa(len(r.Permissions)), + } + } + output.Table(headers, rows) + output.Showing(len(result.Data), result.Pagination.TotalItems, "roles") + return nil + }, +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get role details", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.Role(args[0])) + if err != nil { + return fmt.Errorf("failed to get role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to get role: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + output.Header("Role Details") + output.KeyValue("ID", result.Data.ID) + output.KeyValue("Name", result.Data.Name) + if result.Data.Description != nil { + output.KeyValue("Description", *result.Data.Description) + } + output.KeyValue("Built-in", strconv.FormatBool(result.Data.BuiltIn)) + output.KeyValue("Assigned users", strconv.Itoa(result.Data.AssignedUserCount)) + output.KeyValue("Permissions", strconv.Itoa(len(result.Data.Permissions))) + if len(result.Data.Permissions) > 0 { + rows := make([][]string, len(result.Data.Permissions)) + for i, p := range result.Data.Permissions { + rows[i] = []string{p} + } + output.Table([]string{"PERMISSION"}, rows) + } + return nil + }, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a custom role", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if roleCreateName == "" { + return errors.New("--name is required") + } + if len(roleCreatePermissions) == 0 { + return errors.New("at least one --permission is required") + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + req := roletypes.CreateRole{ + Name: roleCreateName, + Permissions: roleCreatePermissions, + } + if cmd.Flags().Changed("description") { + req.Description = &roleCreateDescription + } + + resp, err := c.Post(cmd.Context(), types.Endpoints.Roles(), req) + if err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + output.Success("Role %s created", result.Data.Name) + output.KeyValue("ID", result.Data.ID) + output.KeyValue("Permissions", strconv.Itoa(len(result.Data.Permissions))) + return nil + }, +} + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a custom role (built-in roles are immutable)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + + // The PUT contract requires every field — fetch the current state + // and overlay only the flags the caller set, so partial updates work + // from the CLI without forcing the user to retype everything. + current, err := fetchRoleInternal(cmd, args[0]) + if err != nil { + return err + } + + req := roletypes.UpdateRole{ + Name: current.Name, + Description: current.Description, + Permissions: current.Permissions, + } + if cmd.Flags().Changed("name") { + req.Name = roleUpdateName + } + if cmd.Flags().Changed("description") { + req.Description = &roleUpdateDescription + } + if cmd.Flags().Changed("permission") { + req.Permissions = roleUpdatePermissions + } + + resp, err := c.Put(cmd.Context(), types.Endpoints.Role(args[0]), req) + if err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + + output.Success("Role updated") + return nil + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm", "remove"}, + Short: "Delete a custom role (built-in roles cannot be deleted)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if !forceFlag { + confirmed, err := cmdutil.Confirm(cmd, fmt.Sprintf("Delete role %s? This removes every assignment of it.", args[0])) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Cancelled") + return nil + } + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Delete(cmd.Context(), types.Endpoints.Role(args[0])) + if err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + output.Success("Role deleted") + return nil + }, +} + +// ---------- Permission manifest ---------- + +var permissionsCmd = &cobra.Command{ + Use: "permissions", + Aliases: []string{"perms"}, + Short: "List every permission the server recognizes (the manifest)", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.RolesAvailablePermissions()) + if err != nil { + return fmt.Errorf("failed to load permission manifest: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[roletypes.PermissionsManifest] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to load permission manifest: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + for _, group := range result.Data.Resources { + output.Header("%s (%s)", group.Label, group.Scope) + rows := make([][]string, len(group.Actions)) + for i, a := range group.Actions { + rows[i] = []string{a.Permission, a.Label, a.Description} + } + output.Table([]string{"PERMISSION", "LABEL", "DESCRIPTION"}, rows) + } + return nil + }, +} + +// ---------- User assignments ---------- + +var assignmentsCmd = &cobra.Command{ + Use: "assignments ", + Short: "List a user's role assignments", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.UserRoleAssignments(args[0])) + if err != nil { + return fmt.Errorf("failed to list assignments: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[[]roletypes.RoleAssignment] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to list assignments: %w", err) + } + + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + + if len(result.Data) == 0 { + output.Info("No role assignments for user %s", args[0]) + return nil + } + rows := make([][]string, len(result.Data)) + for i, a := range result.Data { + scope := "global" + if a.EnvironmentID != nil { + scope = *a.EnvironmentID + } + rows[i] = []string{a.RoleID, scope, a.Source, a.CreatedAt.Format("2006-01-02 15:04")} + } + output.Table([]string{"ROLE", "SCOPE", "SOURCE", "CREATED"}, rows) + return nil + }, +} + +var assignCmd = &cobra.Command{ + Use: "assign ", + Short: "Replace a user's manual role assignments", + Long: "Replace every MANUAL role assignment on the user with the set " + + "passed via --role. OIDC-sourced assignments are left untouched — " + + "manage those via OIDC role mappings.\n\n" + + "Each --role flag accepts `[:]`. Omit the env id for " + + "a global assignment. Pass --role multiple times to assign more than " + + "one role. Pass --role \"\" (empty) once to clear every manual " + + "assignment.", + Example: ` arcane admin roles assign u_123 --role role_editor:env_prod --role role_viewer + arcane admin roles assign u_123 --role role_admin + arcane admin roles assign u_123 --role "" # clear all manual assignments`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + req := roletypes.SetUserAssignments{ + Assignments: parseAssignmentsInternal(assignRoles), + } + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := c.Put(cmd.Context(), types.Endpoints.UserRoleAssignments(args[0]), req) + if err != nil { + return fmt.Errorf("failed to set assignments: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[[]roletypes.RoleAssignment] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return fmt.Errorf("failed to set assignments: %w", err) + } + if jsonOutput { + return cmdutil.PrintJSON(result.Data) + } + output.Success("User %s now has %d role assignment(s)", args[0], len(result.Data)) + return nil + }, +} + +// ---------- helpers ---------- + +// parseAssignmentsInternal converts CLI --role tokens into the request payload. +// Format: "" (global) or ":" (env-scoped). A single +// empty token means "clear all assignments". +func parseAssignmentsInternal(tokens []string) []roletypes.UserAssignmentInput { + if len(tokens) == 1 && strings.TrimSpace(tokens[0]) == "" { + return []roletypes.UserAssignmentInput{} + } + out := make([]roletypes.UserAssignmentInput, 0, len(tokens)) + for _, raw := range tokens { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + roleID, envID, hasEnv := strings.Cut(token, ":") + entry := roletypes.UserAssignmentInput{RoleID: roleID} + if hasEnv && envID != "" { + entry.EnvironmentID = new(envID) + } + out = append(out, entry) + } + return out +} + +func fetchRoleInternal(cmd *cobra.Command, id string) (*roletypes.Role, error) { + c, err := cmdutil.ClientFromCommand(cmd) + if err != nil { + return nil, err + } + resp, err := c.Get(cmd.Context(), types.Endpoints.Role(id)) + if err != nil { + return nil, fmt.Errorf("failed to load current role: %w", err) + } + defer func() { _ = resp.Body.Close() }() + var result base.ApiResponse[roletypes.Role] + if err := cmdutil.DecodeJSON(resp, &result); err != nil { + return nil, fmt.Errorf("failed to load current role: %w", err) + } + return &result.Data, nil +} + +func init() { + RolesCmd.AddCommand(listCmd) + RolesCmd.AddCommand(getCmd) + RolesCmd.AddCommand(createCmd) + RolesCmd.AddCommand(updateCmd) + RolesCmd.AddCommand(deleteCmd) + RolesCmd.AddCommand(permissionsCmd) + RolesCmd.AddCommand(assignmentsCmd) + RolesCmd.AddCommand(assignCmd) + + listCmd.Flags().IntVarP(&limitFlag, "limit", "n", 20, "Number of roles to show") + listCmd.Flags().IntVar(&startFlag, "start", 0, "Offset for pagination") + listCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + getCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + createCmd.Flags().StringVar(&roleCreateName, "name", "", "Role name (required)") + createCmd.Flags().StringVarP(&roleCreateDescription, "description", "d", "", "Role description") + createCmd.Flags().StringArrayVarP(&roleCreatePermissions, "permission", "p", nil, "Permission to grant (repeatable, e.g. containers:start)") + createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + updateCmd.Flags().StringVar(&roleUpdateName, "name", "", "New role name") + updateCmd.Flags().StringVarP(&roleUpdateDescription, "description", "d", "", "New role description") + updateCmd.Flags().StringArrayVarP(&roleUpdatePermissions, "permission", "p", nil, "Replace the permission set (repeatable)") + updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + deleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Force deletion without confirmation") + + permissionsCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + assignmentsCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + + assignCmd.Flags().StringArrayVar(&assignRoles, "role", nil, "Role to grant as `[:]` (repeatable). Pass --role \"\" to clear all manual assignments.") + assignCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + _ = assignCmd.MarkFlagRequired("role") +} diff --git a/cli/pkg/admin/users/cmd.go b/cli/pkg/admin/users/cmd.go index b88709d4bd..7683c7b8a8 100644 --- a/cli/pkg/admin/users/cmd.go +++ b/cli/pkg/admin/users/cmd.go @@ -28,14 +28,12 @@ var ( userCreatePassword string userCreateDisplayName string userCreateEmail string - userCreateRoles []string ) var ( userUpdateUsername string userUpdateDisplayName string userUpdateEmail string - userUpdateRoles []string ) // UsersCmd is the parent command for user operations @@ -43,6 +41,52 @@ var UsersCmd = &cobra.Command{ Use: "users", Aliases: []string{"user", "usr"}, Short: "Manage users", + Long: "Manage users. Role assignments are managed separately via " + + "`arcane admin roles assign` — the legacy `--role` flag has been " + + "removed alongside the binary admin/user model.", +} + +// summarizeRoleAssignments turns a user's role-assignment list into a short +// human label for the list table — e.g. "Admin (global)", "Editor on 2 envs", +// or "Admin (global) + 1 more". Returns "—" when the user holds none. +func summarizeRoleAssignments(assignments []user.RoleAssignmentSummary) string { + if len(assignments) == 0 { + return "—" + } + type bucket struct { + envScoped int + hasGlobal bool + } + buckets := map[string]*bucket{} + order := []string{} + for _, a := range assignments { + b, ok := buckets[a.RoleID] + if !ok { + b = &bucket{} + buckets[a.RoleID] = b + order = append(order, a.RoleID) + } + if a.EnvironmentID == nil { + b.hasGlobal = true + } else { + b.envScoped++ + } + } + parts := make([]string, 0, len(order)) + for _, roleID := range order { + b := buckets[roleID] + switch { + case b.hasGlobal && b.envScoped == 0: + parts = append(parts, roleID+" (global)") + case b.hasGlobal && b.envScoped > 0: + parts = append(parts, fmt.Sprintf("%s (global +%d envs)", roleID, b.envScoped)) + case b.envScoped == 1: + parts = append(parts, roleID+" on 1 env") + default: + parts = append(parts, fmt.Sprintf("%s on %d envs", roleID, b.envScoped)) + } + } + return strings.Join(parts, ", ") } var listCmd = &cobra.Command{ @@ -82,7 +126,7 @@ var listCmd = &cobra.Command{ return nil } - headers := []string{"ID", "USERNAME", "DISPLAY NAME", "EMAIL", "ROLES"} + headers := []string{"ID", "USERNAME", "DISPLAY NAME", "EMAIL", "ROLE ASSIGNMENTS"} rows := make([][]string, len(result.Data)) for i, usr := range result.Data { displayName := "" @@ -93,13 +137,12 @@ var listCmd = &cobra.Command{ if usr.Email != nil { email = *usr.Email } - roles := strings.Join(usr.Roles, ", ") rows[i] = []string{ usr.ID, usr.Username, displayName, email, - roles, + summarizeRoleAssignments(usr.RoleAssignments), } } @@ -110,8 +153,11 @@ var listCmd = &cobra.Command{ } var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new user", + Use: "create", + Short: "Create a new user", + Long: "Create a new user. The user is created without any role " + + "assignments — grant them via `arcane admin roles assign ` " + + "after creation.", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { c, err := client.NewFromConfig() @@ -139,9 +185,6 @@ var createCmd = &cobra.Command{ if cmd.Flags().Changed("email") { req.Email = &userCreateEmail } - if cmd.Flags().Changed("role") { - req.Roles = userCreateRoles - } resp, err := c.Post(cmd.Context(), types.Endpoints.Users(), req) if err != nil { @@ -169,7 +212,7 @@ var createCmd = &cobra.Command{ output.Success("User %s created successfully", result.Data.Username) output.KeyValue("ID", result.Data.ID) output.KeyValue("Username", result.Data.Username) - output.KeyValue("Roles", strings.Join(result.Data.Roles, ", ")) + output.Info("No roles assigned. Use `arcane admin roles assign %s --role ` to grant access.", result.Data.ID) return nil }, } @@ -214,15 +257,26 @@ var getCmd = &cobra.Command{ if result.Data.Email != nil { output.KeyValue("Email", *result.Data.Email) } - output.KeyValue("Roles", strings.Join(result.Data.Roles, ", ")) output.KeyValue("Created", result.Data.CreatedAt) + output.KeyValue("Role Assignments", summarizeRoleAssignments(result.Data.RoleAssignments)) + if len(result.Data.RoleAssignments) > 0 { + rows := make([][]string, len(result.Data.RoleAssignments)) + for i, a := range result.Data.RoleAssignments { + env := "global" + if a.EnvironmentID != nil { + env = *a.EnvironmentID + } + rows[i] = []string{a.RoleID, env, a.Source} + } + output.Table([]string{"ROLE", "SCOPE", "SOURCE"}, rows) + } return nil }, } var updateCmd = &cobra.Command{ Use: "update ", - Short: "Update user", + Short: "Update user profile (role assignments managed separately)", Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -241,9 +295,6 @@ var updateCmd = &cobra.Command{ if cmd.Flags().Changed("email") { req.Email = &userUpdateEmail } - if cmd.Flags().Changed("role") { - req.Roles = userUpdateRoles - } resp, err := c.Put(cmd.Context(), types.Endpoints.User(args[0]), req) if err != nil { @@ -321,7 +372,6 @@ func init() { createCmd.Flags().StringVar(&userCreatePassword, "password", "", "Password (if omitted, will prompt securely)") createCmd.Flags().StringVar(&userCreateDisplayName, "display-name", "", "Display name") createCmd.Flags().StringVar(&userCreateEmail, "email", "", "Email address") - createCmd.Flags().StringArrayVar(&userCreateRoles, "role", nil, "User role") createCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") _ = createCmd.MarkFlagRequired("username") @@ -330,7 +380,6 @@ func init() { updateCmd.Flags().StringVar(&userUpdateUsername, "username", "", "New username") updateCmd.Flags().StringVar(&userUpdateDisplayName, "display-name", "", "Display name") updateCmd.Flags().StringVar(&userUpdateEmail, "email", "", "Email address") - updateCmd.Flags().StringArrayVar(&userUpdateRoles, "role", nil, "User role") updateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") deleteCmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Force deletion without confirmation") diff --git a/cli/pkg/alerts/cmd.go b/cli/pkg/alerts/cmd.go deleted file mode 100644 index f76876be9f..0000000000 --- a/cli/pkg/alerts/cmd.go +++ /dev/null @@ -1,101 +0,0 @@ -package alerts - -import ( - "fmt" - "net/url" - "strings" - - "github.com/getarcaneapp/arcane/cli/internal/cmdutil" - "github.com/getarcaneapp/arcane/cli/internal/output" - "github.com/getarcaneapp/arcane/cli/internal/types" - "github.com/getarcaneapp/arcane/types/base" - dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard" - "github.com/spf13/cobra" -) - -var debugAllGood bool - -// AlertsCmd shows dashboard alerts/action items. -var AlertsCmd = &cobra.Command{ - Use: "alerts", - Aliases: []string{"alert", "actionitems"}, - Short: "Show dashboard alerts", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - c, err := cmdutil.ClientFromCommand(cmd) - if err != nil { - return err - } - - path := types.Endpoints.DashboardActionItems(c.EnvID()) - if debugAllGood { - parsed, err := url.Parse(path) - if err != nil { - return fmt.Errorf("failed to parse endpoint path: %w", err) - } - q := parsed.Query() - q.Set("debugAllGood", "true") - parsed.RawQuery = q.Encode() - path = parsed.String() - } - - resp, err := c.Get(cmd.Context(), path) - if err != nil { - return fmt.Errorf("failed to get dashboard alerts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[dashboardtypes.ActionItems] - if err := cmdutil.DecodeJSON(resp, &result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if cmdutil.JSONOutputEnabled(cmd) { - return cmdutil.PrintJSON(result.Data) - } - - items := result.Data.Items - if len(items) == 0 { - output.Success("No alerts. Environment is good.") - return nil - } - - output.Header("Dashboard Alerts") - headers := []string{"KIND", "COUNT", "SEVERITY"} - rows := make([][]string, 0, len(items)) - for _, item := range items { - rows = append(rows, []string{ - formatActionItemKind(item.Kind), - fmt.Sprintf("%d", item.Count), - strings.ToUpper(string(item.Severity)), - }) - } - output.Table(headers, rows) - output.Info("Total: %d alert(s)", len(items)) - return nil - }, -} - -func formatActionItemKind(kind dashboardtypes.ActionItemKind) string { - switch kind { - case dashboardtypes.ActionItemKindStoppedContainers: - return "Stopped Containers" - case dashboardtypes.ActionItemKindImageUpdates: - return "Image Updates" - case dashboardtypes.ActionItemKindActionableVulnerabilities: - return "Actionable Vulnerabilities" - case dashboardtypes.ActionItemKindExpiringKeys: - return "Expiring Keys" - default: - value := strings.TrimSpace(string(kind)) - if value == "" { - return "-" - } - return strings.ReplaceAll(value, "_", " ") - } -} - -func init() { - AlertsCmd.Flags().Bool("json", false, "Output in JSON format") - AlertsCmd.Flags().BoolVar(&debugAllGood, "debug-all-good", false, "Force no alerts (debug mode)") -} diff --git a/cli/pkg/auth/cmd.go b/cli/pkg/auth/cmd.go index c9ffda6a61..83c62468e3 100644 --- a/cli/pkg/auth/cmd.go +++ b/cli/pkg/auth/cmd.go @@ -2,9 +2,11 @@ package auth import ( "encoding/json" + "errors" "fmt" "io" "os" + "strconv" "strings" "time" @@ -86,7 +88,7 @@ var loginCmd = &cobra.Command{ for { if time.Now().After(expiresAt) { - return fmt.Errorf("device authorization expired; run login again") + return errors.New("device authorization expired; run login again") } select { @@ -116,9 +118,9 @@ var loginCmd = &cobra.Command{ ticker.Reset(pollInterval) continue case "expired_token": - return fmt.Errorf("device authorization expired; run login again") + return errors.New("device authorization expired; run login again") case "access_denied": - return fmt.Errorf("device authorization denied") + return errors.New("device authorization denied") default: return fmt.Errorf("device token exchange failed (status %d): %s", tokenResp.StatusCode, strings.TrimSpace(string(tokenBody))) } @@ -129,7 +131,7 @@ var loginCmd = &cobra.Command{ return fmt.Errorf("failed to parse token response: %w", err) } if !tokenResult.Success || tokenResult.Token == "" { - return fmt.Errorf("device token exchange failed: unexpected response from server") + return errors.New("device token exchange failed: unexpected response from server") } if cmdutil.JSONOutputEnabled(cmd) || jsonOutput { @@ -437,9 +439,9 @@ var oidcStatusCmd = &cobra.Command{ } output.Header("OIDC Status") - output.KeyValue("Environment Forced", fmt.Sprintf("%t", result.EnvForced)) - output.KeyValue("Environment Configured", fmt.Sprintf("%t", result.EnvConfigured)) - output.KeyValue("Merge Accounts", fmt.Sprintf("%t", result.MergeAccounts)) + output.KeyValue("Environment Forced", strconv.FormatBool(result.EnvForced)) + output.KeyValue("Environment Configured", strconv.FormatBool(result.EnvConfigured)) + output.KeyValue("Merge Accounts", strconv.FormatBool(result.MergeAccounts)) return nil }, } diff --git a/cli/pkg/auth/federated.go b/cli/pkg/auth/federated.go new file mode 100644 index 0000000000..4f4f20b23a --- /dev/null +++ b/cli/pkg/auth/federated.go @@ -0,0 +1,234 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/getarcaneapp/arcane/cli/internal/ci" + "github.com/getarcaneapp/arcane/cli/internal/client" + "github.com/getarcaneapp/arcane/cli/internal/cmdutil" + "github.com/getarcaneapp/arcane/cli/internal/config" + "github.com/getarcaneapp/arcane/cli/internal/output" + "github.com/getarcaneapp/arcane/cli/internal/types" + federatedtypes "github.com/getarcaneapp/arcane/types/federated" + "github.com/spf13/cobra" +) + +const maxFederatedErrorBody = 4096 + +var federatedCmd = &cobra.Command{ + Use: "federated", + Short: "Exchange a CI OIDC token for a short-lived Arcane token", + SilenceUsage: true, + Long: `Exchange an external OIDC token for a short-lived Arcane bearer token. + +GitHub Actions example: + permissions: { id-token: write, contents: read } + steps: + - run: | + eval "$(arcane-cli auth federated \ + --server https://arcane.example.com \ + --audience https://arcane.example.com --export)" + - run: arcane-cli projects up my-app`, + RunE: func(cmd *cobra.Command, args []string) error { + server, _ := cmd.Flags().GetString("server") + audience, _ := cmd.Flags().GetString("audience") + provider, _ := cmd.Flags().GetString("provider") + persist, _ := cmd.Flags().GetBool("persist") + exportOutput, _ := cmd.Flags().GetBool("export") + + if cmdutil.JSONOutputEnabled(cmd) && exportOutput { + return errors.New("--json and --export cannot be used together") + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if strings.TrimSpace(server) != "" { + cfg.ServerURL = strings.TrimSpace(server) + } + if strings.TrimSpace(audience) == "" { + audience = cfg.FederatedAudience + } + + subjectToken, tokenSource, err := resolveFederatedSubjectTokenInternal(cmd, provider, audience) + if err != nil { + return err + } + + c, err := client.NewUnauthenticated(cfg) + if err != nil { + return err + } + + tokenResp, err := exchangeFederatedTokenInternal(cmd, c, strings.TrimSpace(subjectToken), strings.TrimSpace(audience)) + if err != nil { + return err + } + if tokenResp.AccessToken == "" { + return errors.New("federated token exchange failed: empty access token") + } + + expiresAt := time.Now().UTC().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + if persist { + cfg.JWTToken = tokenResp.AccessToken + cfg.APIKey = "" + cfg.RefreshToken = "" + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save federated token: %w", err) + } + } + + switch { + case cmdutil.JSONOutputEnabled(cmd): + resultBytes, err := json.MarshalIndent(map[string]any{ + "token": tokenResp.AccessToken, + "tokenType": tokenResp.TokenType, + "expiresIn": tokenResp.ExpiresIn, + "expiresAt": expiresAt.Format(time.RFC3339), + "issuedTokenType": tokenResp.IssuedTokenType, + "source": tokenSource, + "persisted": persist, + }, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(resultBytes)) + case exportOutput: + fmt.Printf("export ARCANE_TOKEN=%s\n", shellQuoteInternal(tokenResp.AccessToken)) + fmt.Printf("export ARCANE_TOKEN_EXPIRES_AT=%s\n", shellQuoteInternal(expiresAt.Format(time.RFC3339))) + default: + output.Success("Federated token exchange successful") + output.KeyValue("Token source", tokenSource) + output.KeyValue("Expires at", expiresAt.Format(time.RFC3339)) + if persist { + path, _ := config.ConfigPath() + output.KeyValue("JWT token saved to config", path) + } else { + output.Info("Use --export, --json, or --persist to consume the token.") + } + } + + return nil + }, +} + +func init() { + AuthCmd.AddCommand(federatedCmd) + + federatedCmd.Flags().String("server", "", "Arcane server URL for this exchange") + federatedCmd.Flags().String("audience", "", "Audience to request from the external OIDC provider") + federatedCmd.Flags().String("token", "", "External OIDC token to exchange") + federatedCmd.Flags().String("token-file", "", "Path to a file containing the external OIDC token") + federatedCmd.Flags().Bool("token-stdin", false, "Read the external OIDC token from stdin") + federatedCmd.Flags().String("provider", ci.ProviderAuto, "OIDC token source: auto, github, gitlab, or generic") + federatedCmd.Flags().Bool("persist", false, "Persist the exchanged Arcane token in CLI config") + federatedCmd.Flags().Bool("export", false, "Print shell exports for ARCANE_TOKEN and ARCANE_TOKEN_EXPIRES_AT") + federatedCmd.Flags().Bool("json", false, "Output in JSON format") +} + +func resolveFederatedSubjectTokenInternal(cmd *cobra.Command, provider string, audience string) (string, string, error) { + token, _ := cmd.Flags().GetString("token") + if strings.TrimSpace(token) != "" { + return strings.TrimSpace(token), "flag", nil + } + + tokenFile, _ := cmd.Flags().GetString("token-file") + if strings.TrimSpace(tokenFile) != "" { + data, err := os.ReadFile(strings.TrimSpace(tokenFile)) + if err != nil { + return "", "", fmt.Errorf("failed to read token file: %w", err) + } + token = strings.TrimSpace(string(data)) + if token == "" { + return "", "", errors.New("token file is empty") + } + return token, "file", nil + } + + tokenStdin, _ := cmd.Flags().GetBool("token-stdin") + if tokenStdin || stdinHasDataInternal() { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", "", fmt.Errorf("failed to read token from stdin: %w", err) + } + token = strings.TrimSpace(string(data)) + if token == "" { + return "", "", errors.New("stdin token is empty") + } + return token, "stdin", nil + } + + return ci.DetectToken(cmd.Context(), provider, audience, os.Getenv, &http.Client{Timeout: 10 * time.Second}) +} + +func exchangeFederatedTokenInternal(cmd *cobra.Command, c *client.Client, subjectToken string, audience string) (*federatedtypes.FederatedTokenResponse, error) { + form := url.Values{} + form.Set("grant_type", federatedtypes.TokenExchangeGrantType) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", federatedtypes.SubjectTokenTypeJWT) + form.Set("requested_token_type", federatedtypes.RequestedTokenTypeAccessJWT) + if strings.TrimSpace(audience) != "" { + form.Set("audience", strings.TrimSpace(audience)) + } + + resp, err := c.RequestRaw(cmd.Context(), http.MethodPost, types.Endpoints.AuthFederated(), strings.NewReader(form.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + if err != nil { + return nil, fmt.Errorf("federated token exchange failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxFederatedErrorBody)) + if err != nil { + return nil, fmt.Errorf("failed to read federated token response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("federated token exchange failed (status %d): %s", resp.StatusCode, redactedFederatedExchangeMessageInternal(body)) + } + + var tokenResp federatedtypes.FederatedTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse federated token response: %w", err) + } + return &tokenResp, nil +} + +func redactedFederatedExchangeMessageInternal(body []byte) string { + var oauthErr struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` //nolint:tagliatelle // RFC 6749 wire shape is snake_case. + } + if err := json.Unmarshal(body, &oauthErr); err == nil { + if strings.TrimSpace(oauthErr.ErrorDescription) != "" { + return strings.TrimSpace(oauthErr.ErrorDescription) + } + if strings.TrimSpace(oauthErr.Error) != "" { + return strings.TrimSpace(oauthErr.Error) + } + } + msg := strings.TrimSpace(string(body)) + if msg == "" { + return "request rejected" + } + return msg +} + +func stdinHasDataInternal() bool { + info, err := os.Stdin.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) == 0 +} + +func shellQuoteInternal(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/cli/pkg/config/cmd.go b/cli/pkg/config/cmd.go index f84d747a42..6f541bc2a3 100644 --- a/cli/pkg/config/cmd.go +++ b/cli/pkg/config/cmd.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "io" "net/http" @@ -16,13 +17,14 @@ import ( ) var ( - setServerURL string - setAPIKey string - setJWTToken string - setEnvironment string - setLogLevel string - setDefaultLimit int - setResourceLimit []string + setServerURL string + setAPIKey string + setJWTToken string + setEnvironment string + setFederatedAudience string + setLogLevel string + setDefaultLimit int + setResourceLimit []string ) // ConfigCmd is the command for managing API configuration @@ -47,13 +49,10 @@ var configShowCmd = &cobra.Command{ fmt.Printf("JWT Token: %s\n", maskAPIKey(cfg.JWTToken)) fmt.Printf("Refresh Token: %s\n", maskAPIKey(cfg.RefreshToken)) fmt.Printf("Default Environment: %s\n", maskIfEmpty(cfg.DefaultEnvironment, "0 (local)")) + fmt.Printf("Federated Audience: %s\n", maskIfEmpty(cfg.FederatedAudience, "(not set)")) fmt.Printf("Log Level: %s\n", maskIfEmpty(cfg.LogLevel, "info (default)")) fmt.Printf("CLI Update Channel: %s\n", maskIfEmpty(cfg.CLIUpdateChannel, "(auto)")) - globalLimit := cfg.Pagination.Default.Limit - if globalLimit <= 0 { - globalLimit = cfg.DefaultLimit - } - fmt.Printf("Pagination Default: %s\n", maskIfEmpty(intToString(globalLimit), "(not set)")) + fmt.Printf("Pagination Default: %s\n", maskIfEmpty(intToString(cfg.Pagination.Default.Limit), "(not set)")) fmt.Println("\nPagination Resources:") printed := 0 @@ -136,7 +135,7 @@ Legacy flag syntax (flags shown below) is still supported: updated = updated || updatedByFlags if !updated { - return fmt.Errorf("no configuration values provided. Use `arcane config set ` (e.g. `arcane config set server-url http://localhost:3552`) or legacy flags (--server-url, --api-key, --jwt-token, --environment, --log-level, --default-limit, --resource-limit)") + return errors.New("no configuration values provided. Use `arcane config set ` (e.g. `arcane config set server-url http://localhost:3552`) or legacy flags (--server-url, --api-key, --jwt-token, --environment, --log-level, --default-limit, --resource-limit)") } if err := config.Save(cfg); err != nil { @@ -188,7 +187,7 @@ var configTestCmd = &cobra.Command{ if cfg.JWTToken != "" { req.Header.Set("Authorization", "Bearer "+cfg.JWTToken) } else { - req.Header.Set("X-API-KEY", cfg.APIKey) + req.Header.Set("X-Api-Key", cfg.APIKey) } resp, err := httpClient.Do(req) @@ -277,6 +276,7 @@ func init() { configSetCmd.Flags().StringVar(&setAPIKey, "api-key", "", "API key for authentication") configSetCmd.Flags().StringVar(&setJWTToken, "jwt-token", "", "JWT access token for authentication (Bearer token)") configSetCmd.Flags().StringVar(&setEnvironment, "environment", "", "Default environment ID") + configSetCmd.Flags().StringVar(&setFederatedAudience, "federated-audience", "", "Default audience for federated authentication") configSetCmd.Flags().StringVar(&setLogLevel, "log-level", "", "Default log level (debug, info, warn, error)") configSetCmd.Flags().IntVar(&setDefaultLimit, "default-limit", 0, "Global default list limit for paginated resources (0 clears)") configSetCmd.Flags().StringSliceVar(&setResourceLimit, "resource-limit", nil, "Per-resource list limit in the form resource=limit (repeatable, 0 clears)") @@ -352,6 +352,12 @@ func applyConfigSetFlags(cmd *cobra.Command, cfg *clitypes.Config) (bool, error) updated = true } + if setFederatedAudience != "" { + cfg.FederatedAudience = setFederatedAudience + fmt.Printf("Set federated_audience = %s\n", setFederatedAudience) + updated = true + } + if setLogLevel != "" { cfg.LogLevel = setLogLevel fmt.Printf("Set log_level = %s\n", setLogLevel) @@ -360,7 +366,7 @@ func applyConfigSetFlags(cmd *cobra.Command, cfg *clitypes.Config) (bool, error) if cmd.Flags().Changed("default-limit") { if setDefaultLimit < 0 { - return false, fmt.Errorf("--default-limit must be >= 0") + return false, errors.New("--default-limit must be >= 0") } cfg.SetDefaultLimit(setDefaultLimit) if setDefaultLimit == 0 { @@ -435,6 +441,10 @@ func applyConfigSetArg(cfg *clitypes.Config, key, value string) (bool, error) { cfg.DefaultEnvironment = value fmt.Printf("Set default_environment = %s\n", value) return true, nil + case "federated-audience", "federated_audience", "audience": + cfg.FederatedAudience = value + fmt.Printf("Set federated_audience = %s\n", value) + return true, nil case "log-level", "loglevel", "log_level": cfg.LogLevel = value fmt.Printf("Set log_level = %s\n", value) @@ -485,7 +495,7 @@ func applyConfigSetArg(cfg *clitypes.Config, key, value string) (bool, error) { return applyResourceLimitByKey(cfg, key, resource, value) } - return false, fmt.Errorf("unknown config key %q. Supported keys include server-url, api-key, jwt-token, environment, log-level, default-limit, resource-limit, and pagination.resources..limit", key) + return false, fmt.Errorf("unknown config key %q. Supported keys include server-url, api-key, jwt-token, environment, federated-audience, log-level, default-limit, resource-limit, and pagination.resources..limit", key) } func applyResourceLimitByKey(cfg *clitypes.Config, key, resourceValue, limitValue string) (bool, error) { diff --git a/cli/pkg/containers/cmd.go b/cli/pkg/containers/cmd.go index 40b1ffa849..642b96cc21 100644 --- a/cli/pkg/containers/cmd.go +++ b/cli/pkg/containers/cmd.go @@ -3,6 +3,7 @@ package containers import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -150,7 +151,7 @@ func buildContainersListPath(cmd *cobra.Command, c *client.Client, forceHasUpdat path := types.Endpoints.Containers(c.EnvID()) if containersAll { if cmd != nil && (cmd.Flags().Changed("limit") || cmd.Flags().Changed("start")) { - return "", fmt.Errorf("--all cannot be combined with explicit pagination flags") + return "", errors.New("--all cannot be combined with explicit pagination flags") } } else { var err error @@ -264,39 +265,11 @@ var containersStartCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - path := types.Endpoints.ContainerStart(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to start container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[container.ActionResult] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s started successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[base.MessageResponse](cmd, args[0], containerPostActionConfig[base.MessageResponse]{ + endpoint: types.Endpoints.ContainerStart, + failureMessage: "failed to start container", + successVerb: "started", + }) }, } @@ -306,39 +279,11 @@ var containersStopCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - path := types.Endpoints.ContainerStop(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to stop container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[container.ActionResult] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s stopped successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[base.MessageResponse](cmd, args[0], containerPostActionConfig[base.MessageResponse]{ + endpoint: types.Endpoints.ContainerStop, + failureMessage: "failed to stop container", + successVerb: "stopped", + }) }, } @@ -348,39 +293,11 @@ var containersRestartCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - path := types.Endpoints.ContainerRestart(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to restart container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[container.ActionResult] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s restarted successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[base.MessageResponse](cmd, args[0], containerPostActionConfig[base.MessageResponse]{ + endpoint: types.Endpoints.ContainerRestart, + failureMessage: "failed to restart container", + successVerb: "restarted", + }) }, } @@ -390,42 +307,12 @@ var containersUpdateCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - // Updating a container can take a long time as it pulls the image - c.SetTimeout(30 * time.Minute) - - path := types.Endpoints.ContainerUpdate(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to update container: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[container.ActionResult] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Success("Container %s updated successfully", containerDisplayName(resolved)) - return nil + return runContainerPostAction[container.ActionResult](cmd, args[0], containerPostActionConfig[container.ActionResult]{ + endpoint: types.Endpoints.ContainerUpdate, + failureMessage: "failed to update container", + successVerb: "updated", + timeout: 30 * time.Minute, + }) }, } @@ -435,42 +322,60 @@ var containersRedeployCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } + return runContainerPostAction[container.Details](cmd, args[0], containerPostActionConfig[container.Details]{ + endpoint: types.Endpoints.ContainerRedeploy, + failureMessage: "failed to redeploy container", + successVerb: "redeployed", + timeout: 30 * time.Minute, + }) + }, +} - resolved, _, err := resolveContainer(cmd.Context(), c, args[0], false) - if err != nil { - return err - } +type containerPostActionConfig[T any] struct { + endpoint func(string, string) string + failureMessage string + successVerb string + timeout time.Duration +} - c.SetTimeout(30 * time.Minute) +func runContainerPostAction[T any](cmd *cobra.Command, containerRef string, cfg containerPostActionConfig[T]) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } - path := types.Endpoints.ContainerRedeploy(c.EnvID(), resolved.ID) - resp, err := c.Post(cmd.Context(), path, nil) - if err != nil { - return fmt.Errorf("failed to redeploy container: %w", err) - } - defer func() { _ = resp.Body.Close() }() + resolved, _, err := resolveContainer(cmd.Context(), c, containerRef, false) + if err != nil { + return err + } - var result base.ApiResponse[container.Details] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } + if cfg.timeout > 0 { + c.SetTimeout(cfg.timeout) + } - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } + path := cfg.endpoint(c.EnvID(), resolved.ID) + resp, err := c.Post(cmd.Context(), path, nil) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[T] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } - output.Success("Container %s redeployed successfully", containerDisplayName(resolved)) + if jsonOutput { + resultBytes, err := json.MarshalIndent(result.Data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(resultBytes)) return nil - }, + } + + output.Success("Container %s %s successfully", containerDisplayName(resolved), cfg.successVerb) + return nil } var containersDeleteCmd = &cobra.Command{ @@ -666,10 +571,10 @@ var containersCreateCmd = &cobra.Command{ // Validate required fields if req.Name == "" { - return fmt.Errorf("--name is required") + return errors.New("--name is required") } if req.Image == "" { - return fmt.Errorf("--image is required") + return errors.New("--image is required") } path := types.Endpoints.Containers(c.EnvID()) @@ -785,7 +690,7 @@ func containerDisplayName(details *container.Details) string { func resolveContainer(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*container.Details, bool, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, false, fmt.Errorf("container identifier is required") + return nil, false, errors.New("container identifier is required") } details, complete, found, err := fetchContainerByIdentifier(ctx, c, trimmed) diff --git a/cli/pkg/doctor/cmd.go b/cli/pkg/doctor/cmd.go index 529ba78fdb..e66824a6bc 100644 --- a/cli/pkg/doctor/cmd.go +++ b/cli/pkg/doctor/cmd.go @@ -2,6 +2,7 @@ package doctor import ( "encoding/json" + "errors" "fmt" "strings" @@ -41,7 +42,7 @@ var DoctorCmd = &cobra.Command{ cfg := app.Config() if cfg == nil { - return fmt.Errorf("configuration unavailable") + return errors.New("configuration unavailable") } rep := report{Healthy: true} @@ -123,7 +124,7 @@ var DoctorCmd = &cobra.Command{ } if !rep.Healthy { - return fmt.Errorf("diagnostics failed") + return errors.New("diagnostics failed") } return nil }, diff --git a/cli/pkg/environments/cmd.go b/cli/pkg/environments/cmd.go index 97da6a108f..6ff715618c 100644 --- a/cli/pkg/environments/cmd.go +++ b/cli/pkg/environments/cmd.go @@ -2,8 +2,10 @@ package environments import ( "encoding/json" + "errors" "fmt" "sort" + "strconv" "strings" "charm.land/lipgloss/v2" @@ -225,7 +227,7 @@ var switchCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if !prompt.IsInteractive() { - return fmt.Errorf("interactive terminal required; run `arcane config set environment ` instead") + return errors.New("interactive terminal required; run `arcane config set environment ` instead") } cfg, err := config.Load() @@ -251,7 +253,7 @@ var switchCmd = &cobra.Command{ } if len(result.Data) == 0 { - return fmt.Errorf("no environments available") + return errors.New("no environments available") } currentEnv := cfg.DefaultEnvironment @@ -344,7 +346,7 @@ var updateCmd = &cobra.Command{ var req environment.Update if cmd.Flags().Changed("enabled") && cmd.Flags().Changed("disabled") { - return fmt.Errorf("--enabled and --disabled are mutually exclusive") + return errors.New("--enabled and --disabled are mutually exclusive") } if cmd.Flags().Changed("name") { req.Name = &envUpdateName @@ -441,7 +443,7 @@ var versionCmd = &cobra.Command{ output.KeyValue("Revision", result.ShortRevision) output.KeyValue("Go Version", result.GoVersion) output.KeyValue("Build Time", result.BuildTime) - output.KeyValue("Update Available", fmt.Sprintf("%t", result.UpdateAvailable)) + output.KeyValue("Update Available", strconv.FormatBool(result.UpdateAvailable)) return nil }, } diff --git a/cli/pkg/generate/certs.go b/cli/pkg/generate/certs.go index 4c2d5b3a38..79afd820a5 100644 --- a/cli/pkg/generate/certs.go +++ b/cli/pkg/generate/certs.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" "fmt" "math/big" "net" @@ -126,7 +127,7 @@ type serverTLSPaths struct { func generateEdgeMTLSBundleInternal(outDir, envID string, appURL string) (*edgeMTLSPaths, error) { if envID == "" { - return nil, fmt.Errorf("env ID is required") + return nil, errors.New("env ID is required") } if err := os.MkdirAll(outDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create output directory: %w", err) @@ -155,7 +156,7 @@ func generateEdgeMTLSBundleInternal(outDir, envID string, appURL string) (*edgeM if trustDomain != "" { dnsSANs = append(dnsSANs, "arcane-agent."+trustDomain) } - clientTemplate, err := NewEdgeMTLSClientTemplate(fmt.Sprintf("arcane-edge-%s", envID), BuildEdgeMTLSURISAN(appURL, envID), dnsSANs) + clientTemplate, err := NewEdgeMTLSClientTemplate("arcane-edge-"+envID, BuildEdgeMTLSURISAN(appURL, envID), dnsSANs) if err != nil { return nil, err } @@ -187,15 +188,15 @@ func generateEdgeMTLSBundleInternal(outDir, envID string, appURL string) (*edgeM func generateServerTLSBundleInternal(outDir, commonName string, hosts []string, certName string, keyName string) (*serverTLSPaths, error) { if commonName == "" { - return nil, fmt.Errorf("common name is required") + return nil, errors.New("common name is required") } certName = filepath.Base(strings.TrimSpace(certName)) if certName == "." || certName == "" { - return nil, fmt.Errorf("certificate file name is required") + return nil, errors.New("certificate file name is required") } keyName = filepath.Base(strings.TrimSpace(keyName)) if keyName == "." || keyName == "" { - return nil, fmt.Errorf("key file name is required") + return nil, errors.New("key file name is required") } if err := os.MkdirAll(outDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create output directory: %w", err) @@ -249,7 +250,7 @@ func NewEdgeMTLSCATemplate() (*x509.Certificate, error) { func NewEdgeMTLSClientTemplate(commonName string, uriSAN *url.URL, dnsSANs []string) (*x509.Certificate, error) { commonName = strings.TrimSpace(commonName) if commonName == "" { - return nil, fmt.Errorf("common name is required") + return nil, errors.New("common name is required") } template, err := newCertificateTemplateInternal(commonName) if err != nil { @@ -278,7 +279,7 @@ func NewEdgeMTLSClientTemplate(commonName string, uriSAN *url.URL, dnsSANs []str func NewServerTLSTemplate(commonName string, hosts []string) (*x509.Certificate, error) { commonName = strings.TrimSpace(commonName) if commonName == "" { - return nil, fmt.Errorf("common name is required") + return nil, errors.New("common name is required") } template, err := newCertificateTemplateInternal(commonName) if err != nil { diff --git a/cli/pkg/gitops/cmd.go b/cli/pkg/gitops/cmd.go index 798fd2a264..59d7068d6a 100644 --- a/cli/pkg/gitops/cmd.go +++ b/cli/pkg/gitops/cmd.go @@ -3,6 +3,7 @@ package gitops import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -619,7 +620,7 @@ func init() { func resolveGitOpsSync(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*gitops.GitOpsSync, bool, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, false, fmt.Errorf("gitops sync identifier is required") + return nil, false, errors.New("gitops sync identifier is required") } resp, err := c.Get(ctx, types.Endpoints.GitOpsSync(c.EnvID(), trimmed)) diff --git a/cli/pkg/images/cmd.go b/cli/pkg/images/cmd.go index 7da6ef08a0..7ede2f47b7 100644 --- a/cli/pkg/images/cmd.go +++ b/cli/pkg/images/cmd.go @@ -33,6 +33,7 @@ package images import ( "context" "encoding/json" + "errors" "fmt" "io" "mime/multipart" @@ -88,7 +89,7 @@ var imagesListCmd = &cobra.Command{ return err } if imagesInUseOnly && imagesUnusedOnly { - return fmt.Errorf("--inuse and --unused cannot be used together") + return errors.New("--inuse and --unused cannot be used together") } path := types.Endpoints.Images(c.EnvID()) @@ -431,7 +432,7 @@ var imagesPullCmd = &cobra.Command{ } if currentID != event.ID { currentID = event.ID - progressUI.SetLabel(fmt.Sprintf("Downloading %s", event.ID)) + progressUI.SetLabel("Downloading " + event.ID) progressUI.SetTotal(event.ProgressDetail.Total) } progressUI.SetCurrent(event.ProgressDetail.Current) @@ -732,7 +733,7 @@ func init() { func resolveImageID(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (string, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return "", fmt.Errorf("image identifier is required") + return "", errors.New("image identifier is required") } resolvedID, found, err := resolveImageByID(ctx, c, trimmed) diff --git a/cli/pkg/images/updates/cmd.go b/cli/pkg/images/updates/cmd.go index 249b551c57..65544d61da 100644 --- a/cli/pkg/images/updates/cmd.go +++ b/cli/pkg/images/updates/cmd.go @@ -3,6 +3,7 @@ package updates import ( "encoding/json" "fmt" + "strconv" "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/output" @@ -140,7 +141,7 @@ var checkImageCmd = &cobra.Command{ } output.Header("Image Update Status") - output.KeyValue("Has Update", fmt.Sprintf("%t", result.Data.HasUpdate)) + output.KeyValue("Has Update", strconv.FormatBool(result.Data.HasUpdate)) if result.Data.HasUpdate { output.KeyValue("Update Type", result.Data.UpdateType) output.KeyValue("Current Version", result.Data.CurrentVersion) @@ -183,10 +184,10 @@ var summaryCmd = &cobra.Command{ } output.Header("Image Updates Summary") - output.KeyValue("Total Images", fmt.Sprintf("%d", result.Data.TotalImages)) - output.KeyValue("Images with Updates", fmt.Sprintf("%d", result.Data.ImagesWithUpdates)) - output.KeyValue("Digest Updates", fmt.Sprintf("%d", result.Data.DigestUpdates)) - output.KeyValue("Errors", fmt.Sprintf("%d", result.Data.ErrorsCount)) + output.KeyValue("Total Images", strconv.Itoa(result.Data.TotalImages)) + output.KeyValue("Images with Updates", strconv.Itoa(result.Data.ImagesWithUpdates)) + output.KeyValue("Digest Updates", strconv.Itoa(result.Data.DigestUpdates)) + output.KeyValue("Errors", strconv.Itoa(result.Data.ErrorsCount)) return nil }, } diff --git a/cli/pkg/jobs/cmd.go b/cli/pkg/jobs/cmd.go index 358ab493a0..d3b9f37378 100644 --- a/cli/pkg/jobs/cmd.go +++ b/cli/pkg/jobs/cmd.go @@ -2,6 +2,7 @@ package jobs import ( "encoding/json" + "errors" "fmt" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -91,7 +92,7 @@ var updateCmd = &cobra.Command{ } if req.EnvironmentHealthInterval == nil && req.EventCleanupInterval == nil && req.ExpiredSessionsCleanupInterval == nil { - return fmt.Errorf("no updates provided (set at least one interval flag)") + return errors.New("no updates provided (set at least one interval flag)") } resp, err := c.Put(cmd.Context(), types.Endpoints.JobSchedules(c.EnvID()), req) diff --git a/cli/pkg/networks/cmd.go b/cli/pkg/networks/cmd.go index 4780169a50..e92459e0f8 100644 --- a/cli/pkg/networks/cmd.go +++ b/cli/pkg/networks/cmd.go @@ -3,6 +3,7 @@ package networks import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -44,7 +45,7 @@ var listCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if inUseOnlyFlag && unusedOnlyFlag { - return fmt.Errorf("--inuse and --unused cannot be used together") + return errors.New("--inuse and --unused cannot be used together") } c, err := client.NewFromConfig() if err != nil { @@ -345,7 +346,7 @@ func shortID(id string) string { func resolveNetworkID(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (string, string, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return "", "", fmt.Errorf("network identifier is required") + return "", "", errors.New("network identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Network(c.EnvID(), trimmed)) diff --git a/cli/pkg/projects/cmd.go b/cli/pkg/projects/cmd.go index 039a590737..9c1eccbe6e 100644 --- a/cli/pkg/projects/cmd.go +++ b/cli/pkg/projects/cmd.go @@ -3,12 +3,14 @@ package projects import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "time" @@ -116,8 +118,8 @@ func runProjectsList(cmd *cobra.Command, forceHasUpdateFilter bool) error { proj.Name, proj.Status, projectUpdateStatus(proj), - fmt.Sprintf("%d", imageCount), - fmt.Sprintf("%d", updatedCount), + strconv.Itoa(imageCount), + strconv.Itoa(updatedCount), } } output.Table(headers, rows) @@ -132,8 +134,8 @@ func runProjectsList(cmd *cobra.Command, forceHasUpdateFilter bool) error { proj.ID, proj.Name, proj.Status, - fmt.Sprintf("%d", proj.ServiceCount), - fmt.Sprintf("%d", proj.RunningCount), + strconv.Itoa(proj.ServiceCount), + strconv.Itoa(proj.RunningCount), proj.CreatedAt, } } @@ -278,27 +280,11 @@ var upCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectUp(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to start project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to start project: %w", err) - } - - output.Success("Project %s started successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectUp, + failureMessage: "failed to start project", + successMessage: "Project %s started successfully", + }) }, } @@ -308,27 +294,11 @@ var downCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectDown(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to stop project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to stop project: %w", err) - } - - output.Success("Project %s stopped successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectDown, + failureMessage: "failed to stop project", + successMessage: "Project %s stopped successfully", + }) }, } @@ -338,27 +308,11 @@ var restartCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectRestart(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to restart project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to restart project: %w", err) - } - - output.Success("Project %s restarted successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectRestart, + failureMessage: "failed to restart project", + successMessage: "Project %s restarted successfully", + }) }, } @@ -368,30 +322,12 @@ var redeployCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } - - // Redeploying can take a long time as it pulls images and restarts containers - c.SetTimeout(30 * time.Minute) - - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectRedeploy(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to redeploy project: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to redeploy project: %w", err) - } - - output.Success("Project %s redeployed successfully", resolved.Name) - return nil + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectRedeploy, + failureMessage: "failed to redeploy project", + successMessage: "Project %s redeployed successfully", + timeout: 30 * time.Minute, + }) }, } @@ -401,31 +337,48 @@ var pullCmd = &cobra.Command{ Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } + return runProjectPostAction(cmd, args[0], projectPostActionConfig{ + endpoint: types.Endpoints.ProjectPull, + failureMessage: "failed to pull images", + successMessage: "Images pulled successfully for project %s", + timeout: 30 * time.Minute, + }) + }, +} - resolved, _, err := resolveProject(cmd.Context(), c, args[0], false) - if err != nil { - return err - } +type projectPostActionConfig struct { + endpoint func(string, string) string + failureMessage string + successMessage string + timeout time.Duration +} - // Pulling images can take a long time - c.SetTimeout(30 * time.Minute) +func runProjectPostAction(cmd *cobra.Command, projectRef string, cfg projectPostActionConfig) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } - resp, err := c.Post(cmd.Context(), types.Endpoints.ProjectPull(c.EnvID(), resolved.ID), nil) - if err != nil { - return fmt.Errorf("failed to pull images: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if err := cmdutil.EnsureSuccessStatus(resp); err != nil { - return fmt.Errorf("failed to pull images: %w", err) - } + resolved, _, err := resolveProject(cmd.Context(), c, projectRef, false) + if err != nil { + return err + } - output.Success("Images pulled successfully for project %s", resolved.Name) - return nil - }, + if cfg.timeout > 0 { + c.SetTimeout(cfg.timeout) + } + + resp, err := c.Post(cmd.Context(), cfg.endpoint(c.EnvID(), resolved.ID), nil) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + if err := cmdutil.EnsureSuccessStatus(resp); err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + + output.Success(cfg.successMessage, resolved.Name) + return nil } var createCmd = &cobra.Command{ @@ -705,7 +658,7 @@ func init() { func resolveProject(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*project.Details, bool, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, false, fmt.Errorf("project identifier is required") + return nil, false, errors.New("project identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Project(c.EnvID(), trimmed)) diff --git a/cli/pkg/registries/cmd.go b/cli/pkg/registries/cmd.go index 80110ae714..464bfbf1f7 100644 --- a/cli/pkg/registries/cmd.go +++ b/cli/pkg/registries/cmd.go @@ -2,6 +2,7 @@ package registries import ( "encoding/json" + "errors" "fmt" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -220,7 +221,7 @@ var updateCmd = &cobra.Command{ req := make(map[string]any) if cmd.Flags().Changed("enabled") && cmd.Flags().Changed("disabled") { - return fmt.Errorf("--enabled and --disabled are mutually exclusive") + return errors.New("--enabled and --disabled are mutually exclusive") } if cmd.Flags().Changed("url") { req["url"] = registryUpdateURL @@ -239,7 +240,7 @@ var updateCmd = &cobra.Command{ } if len(req) == 0 { - return fmt.Errorf("no updates provided; use --url, --username, --password, --enabled, or --disabled") + return errors.New("no updates provided; use --url, --username, --password, --enabled, or --disabled") } resp, err := c.Put(cmd.Context(), types.Endpoints.ContainerRegistry(args[0]), req) diff --git a/cli/pkg/repos/cmd.go b/cli/pkg/repos/cmd.go index a21b0bd3dc..058874a513 100644 --- a/cli/pkg/repos/cmd.go +++ b/cli/pkg/repos/cmd.go @@ -3,11 +3,13 @@ package repos import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "os" + "strconv" "strings" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -524,7 +526,7 @@ var filesCmd = &cobra.Command{ for i, node := range files { size := "" if node.Type == gitops.FileTreeNodeTypeFile { - size = fmt.Sprintf("%d", node.Size) + size = strconv.FormatInt(node.Size, 10) } rows[i] = []string{ node.Name, @@ -580,7 +582,7 @@ var syncCmd = &cobra.Command{ func resolveGitRepository(ctx context.Context, c *client.Client, identifier string) (*gitops.GitRepository, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, fmt.Errorf("repository identifier is required") + return nil, errors.New("repository identifier is required") } // Try direct GET by ID. @@ -598,7 +600,7 @@ func resolveGitRepository(ctx context.Context, c *client.Client, identifier stri } // Fallback: search via list endpoint. - listPath := fmt.Sprintf("%s?limit=200", types.Endpoints.GitRepositories()) + listPath := types.Endpoints.GitRepositories() + "?limit=200" listResp, err := c.Get(ctx, listPath) if err != nil { return nil, fmt.Errorf("failed to search repositories: %w", err) diff --git a/cli/pkg/root.go b/cli/pkg/root.go index 699233cf4f..97c4e71e5a 100644 --- a/cli/pkg/root.go +++ b/cli/pkg/root.go @@ -21,7 +21,6 @@ // # Command Groups // // - admin: Administration & platform management -// - alerts: Show dashboard alerts // - auth: Authentication operations // - config: Manage CLI configuration // - containers: Manage containers @@ -39,8 +38,8 @@ import ( "strings" "time" + "charm.land/fang/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/fang" "github.com/fatih/color" "github.com/getarcaneapp/arcane/cli/internal/config" "github.com/getarcaneapp/arcane/cli/internal/logger" @@ -48,7 +47,6 @@ import ( "github.com/getarcaneapp/arcane/cli/internal/runstate" runtimectx "github.com/getarcaneapp/arcane/cli/internal/runtime" "github.com/getarcaneapp/arcane/cli/pkg/admin" - "github.com/getarcaneapp/arcane/cli/pkg/alerts" "github.com/getarcaneapp/arcane/cli/pkg/auth" "github.com/getarcaneapp/arcane/cli/pkg/completion" configClient "github.com/getarcaneapp/arcane/cli/pkg/config" @@ -256,7 +254,6 @@ func init() { rootCmd.AddCommand(generate.GenerateCmd) rootCmd.AddCommand(version.VersionCmd) rootCmd.AddCommand(auth.AuthCmd) - rootCmd.AddCommand(alerts.AlertsCmd) rootCmd.AddCommand(containers.ContainersCmd) rootCmd.AddCommand(images.ImagesCmd) rootCmd.AddCommand(volumes.VolumesCmd) diff --git a/cli/pkg/selfupdate/cmd.go b/cli/pkg/selfupdate/cmd.go index 57c7d6ceb5..50703f131d 100644 --- a/cli/pkg/selfupdate/cmd.go +++ b/cli/pkg/selfupdate/cmd.go @@ -636,6 +636,7 @@ func downloadFileInternal(ctx context.Context, url, outputPath string) error { return nil } +//nolint:goprintffuncname // internal verbose-logging helper; printf-style by design func verboseCLIUpdateInternal(format string, args ...any) { if !cliUpdateVerbose { return @@ -666,7 +667,7 @@ func findChecksumInternal(checksums string, artifactNames ...string) (string, er } } - for _, line := range strings.Split(checksums, "\n") { + for line := range strings.SplitSeq(checksums, "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { continue @@ -681,7 +682,7 @@ func findChecksumInternal(checksums string, artifactNames ...string) (string, er func checksumEntryNamesInternal(checksums string) []string { names := make([]string, 0) - for _, line := range strings.Split(checksums, "\n") { + for line := range strings.SplitSeq(checksums, "\n") { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { continue diff --git a/cli/pkg/settings/cmd.go b/cli/pkg/settings/cmd.go index bb4c0a6e1a..88f5937899 100644 --- a/cli/pkg/settings/cmd.go +++ b/cli/pkg/settings/cmd.go @@ -28,44 +28,11 @@ var listCmd = &cobra.Command{ Short: "List environment settings", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.Settings(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get settings: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result []settings.PublicSetting - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - headers := []string{"KEY", "TYPE", "VALUE"} - rows := make([][]string, len(result)) - for i, s := range result { - rows[i] = []string{ - s.Key, - s.Type, - s.Value, - } - } - - output.Table(headers, rows) - fmt.Printf("\nTotal: %d settings\n", len(result)) - return nil + return runSettingsList(cmd, settingsListConfig{ + endpoint: types.Endpoints.Settings, + failureMessage: "failed to get settings", + totalLabel: "settings", + }) }, } @@ -120,41 +87,55 @@ var publicCmd = &cobra.Command{ Short: "List public settings", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.SettingsPublic(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get public settings: %w", err) - } - defer func() { _ = resp.Body.Close() }() + return runSettingsList(cmd, settingsListConfig{ + endpoint: types.Endpoints.SettingsPublic, + failureMessage: "failed to get public settings", + totalLabel: "public settings", + }) + }, +} - var result []settings.PublicSetting - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } +type settingsListConfig struct { + endpoint func(string) string + failureMessage string + totalLabel string +} - if jsonOutput { - resultBytes, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil +func runSettingsList(cmd *cobra.Command, cfg settingsListConfig) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } + + resp, err := c.Get(cmd.Context(), cfg.endpoint(c.EnvID())) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + + var result []settings.PublicSetting + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if jsonOutput { + resultBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) } + fmt.Println(string(resultBytes)) + return nil + } - headers := []string{"KEY", "TYPE", "VALUE"} - rows := make([][]string, len(result)) - for i, s := range result { - rows[i] = []string{s.Key, s.Type, s.Value} - } + headers := []string{"KEY", "TYPE", "VALUE"} + rows := make([][]string, len(result)) + for i, s := range result { + rows[i] = []string{s.Key, s.Type, s.Value} + } - output.Table(headers, rows) - fmt.Printf("\nTotal: %d public settings\n", len(result)) - return nil - }, + output.Table(headers, rows) + fmt.Printf("\nTotal: %d %s\n", len(result), cfg.totalLabel) + return nil } func init() { diff --git a/cli/pkg/system/cmd.go b/cli/pkg/system/cmd.go index fed117495a..23700fbf90 100644 --- a/cli/pkg/system/cmd.go +++ b/cli/pkg/system/cmd.go @@ -3,6 +3,7 @@ package system import ( "encoding/json" "fmt" + "strconv" "github.com/getarcaneapp/arcane/cli/internal/client" "github.com/getarcaneapp/arcane/cli/internal/cmdutil" @@ -327,7 +328,7 @@ var upgradeCheckCmd = &cobra.Command{ } output.Header("Upgrade Check") - output.KeyValue("Can Upgrade", fmt.Sprintf("%t", result.CanUpgrade)) + output.KeyValue("Can Upgrade", strconv.FormatBool(result.CanUpgrade)) output.KeyValue("Message", result.Message) if result.Error { output.KeyValue("Error", "true") diff --git a/cli/pkg/templates/cmd.go b/cli/pkg/templates/cmd.go index 96042c84ad..6c51cd3909 100644 --- a/cli/pkg/templates/cmd.go +++ b/cli/pkg/templates/cmd.go @@ -3,12 +3,14 @@ package templates import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" "path/filepath" "sort" + "strconv" "strings" "unicode" @@ -78,7 +80,7 @@ var listCmd = &cobra.Command{ if templateListAll { if cmd.Flags().Changed("limit") || cmd.Flags().Changed("start") { - return fmt.Errorf("--all cannot be combined with explicit pagination flags") + return errors.New("--all cannot be combined with explicit pagination flags") } path = types.Endpoints.TemplatesAll() resp, err := c.Get(cmd.Context(), path) @@ -223,8 +225,8 @@ var contentCmd = &cobra.Command{ output.Header("Template Content") output.KeyValue("Name", result.Data.Template.Name) output.KeyValue("Description", result.Data.Template.Description) - output.KeyValue("Services", fmt.Sprintf("%d", len(result.Data.Services))) - output.KeyValue("Env Variables", fmt.Sprintf("%d", len(result.Data.EnvVariables))) + output.KeyValue("Services", strconv.Itoa(len(result.Data.Services))) + output.KeyValue("Env Variables", strconv.Itoa(len(result.Data.EnvVariables))) fmt.Println("\n--- Compose Content ---") fmt.Println(result.Data.Content) if result.Data.EnvContent != "" { @@ -455,7 +457,7 @@ var getCmd = &cobra.Command{ func resolveTemplate(ctx context.Context, c *client.Client, identifier string) (*template.Template, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, fmt.Errorf("template identifier is required") + return nil, errors.New("template identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Template(trimmed)) @@ -760,7 +762,7 @@ func topTemplatesFromRankedMatches(matches []rankedTemplateMatch, limit int) []t } result := make([]template.Template, 0, limit) - for i := 0; i < limit; i++ { + for i := range limit { result = append(result, matches[i].template) } return result @@ -770,13 +772,13 @@ func minInt(values ...int) int { if len(values) == 0 { return 0 } - min := values[0] + result := values[0] for _, value := range values[1:] { - if value < min { - min = value + if value < result { + result = value } } - return min + return result } func maxInt(a, b int) int { diff --git a/cli/pkg/updater/cmd.go b/cli/pkg/updater/cmd.go index 1bfd280c73..f13aee0aaa 100644 --- a/cli/pkg/updater/cmd.go +++ b/cli/pkg/updater/cmd.go @@ -3,6 +3,7 @@ package updater import ( "encoding/json" "fmt" + "strconv" "time" "github.com/getarcaneapp/arcane/cli/internal/client" @@ -53,8 +54,8 @@ var statusCmd = &cobra.Command{ } output.Header("Updater Status") - output.KeyValue("Updating Containers", fmt.Sprintf("%d", result.Data.UpdatingContainers)) - output.KeyValue("Updating Projects", fmt.Sprintf("%d", result.Data.UpdatingProjects)) + output.KeyValue("Updating Containers", strconv.Itoa(result.Data.UpdatingContainers)) + output.KeyValue("Updating Projects", strconv.Itoa(result.Data.UpdatingProjects)) return nil }, } @@ -93,10 +94,10 @@ var runCmd = &cobra.Command{ } output.Header("Updater Results") - output.KeyValue("Checked", fmt.Sprintf("%d", result.Data.Checked)) - output.KeyValue("Updated", fmt.Sprintf("%d", result.Data.Updated)) - output.KeyValue("Skipped", fmt.Sprintf("%d", result.Data.Skipped)) - output.KeyValue("Failed", fmt.Sprintf("%d", result.Data.Failed)) + output.KeyValue("Checked", strconv.Itoa(result.Data.Checked)) + output.KeyValue("Updated", strconv.Itoa(result.Data.Updated)) + output.KeyValue("Skipped", strconv.Itoa(result.Data.Skipped)) + output.KeyValue("Failed", strconv.Itoa(result.Data.Failed)) output.KeyValue("Duration", result.Data.Duration) return nil }, @@ -136,9 +137,9 @@ var historyCmd = &cobra.Command{ rows := make([][]string, len(result.Data)) for i, h := range result.Data { rows[i] = []string{ - fmt.Sprintf("%d", h.Checked), - fmt.Sprintf("%d", h.Updated), - fmt.Sprintf("%d", h.Failed), + strconv.Itoa(h.Checked), + strconv.Itoa(h.Updated), + strconv.Itoa(h.Failed), h.Duration, } } diff --git a/cli/pkg/volumes/cmd.go b/cli/pkg/volumes/cmd.go index 15caa46c0f..809fc4fcef 100644 --- a/cli/pkg/volumes/cmd.go +++ b/cli/pkg/volumes/cmd.go @@ -3,6 +3,7 @@ package volumes import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -51,7 +52,7 @@ var listCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if inUseOnlyFlag && unusedOnlyFlag { - return fmt.Errorf("--inuse and --unused cannot be used together") + return errors.New("--inuse and --unused cannot be used together") } c, err := client.NewFromConfig() if err != nil { @@ -196,38 +197,12 @@ var countsCmd = &cobra.Command{ Short: "Get volume usage counts", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } - - resp, err := c.Get(cmd.Context(), types.Endpoints.VolumesCounts(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get volume counts: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil - } - - output.Header("Volume Usage Counts") - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal volume counts: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return runVolumeDataCommand(cmd, volumeDataCommandConfig{ + endpoint: types.Endpoints.VolumesCounts, + failureMessage: "failed to get volume counts", + header: "Volume Usage Counts", + marshalMessage: "failed to marshal volume counts", + }) }, } @@ -285,39 +260,55 @@ var sizesCmd = &cobra.Command{ Short: "Get volume sizes", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.NewFromConfig() - if err != nil { - return err - } + return runVolumeDataCommand(cmd, volumeDataCommandConfig{ + endpoint: types.Endpoints.VolumesSizes, + failureMessage: "failed to get volume sizes", + header: "Volume Sizes", + marshalMessage: "failed to marshal volume sizes", + }) + }, +} - resp, err := c.Get(cmd.Context(), types.Endpoints.VolumesSizes(c.EnvID())) - if err != nil { - return fmt.Errorf("failed to get volume sizes: %w", err) - } - defer func() { _ = resp.Body.Close() }() +type volumeDataCommandConfig struct { + endpoint func(string) string + failureMessage string + header string + marshalMessage string +} - var result base.ApiResponse[any] - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } +func runVolumeDataCommand(cmd *cobra.Command, cfg volumeDataCommandConfig) error { + c, err := client.NewFromConfig() + if err != nil { + return err + } + resp, err := c.Get(cmd.Context(), cfg.endpoint(c.EnvID())) + if err != nil { + return fmt.Errorf("%s: %w", cfg.failureMessage, err) + } + defer func() { _ = resp.Body.Close() }() + + var result base.ApiResponse[any] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + resultBytes, err := json.MarshalIndent(result.Data, "", " ") + if err != nil { if jsonOutput { - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(resultBytes)) - return nil + return fmt.Errorf("failed to marshal JSON: %w", err) } + return fmt.Errorf("%s: %w", cfg.marshalMessage, err) + } - output.Header("Volume Sizes") - resultBytes, err := json.MarshalIndent(result.Data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal volume sizes: %w", err) - } + if jsonOutput { fmt.Println(string(resultBytes)) return nil - }, + } + + output.Header("%s", cfg.header) + fmt.Println(string(resultBytes)) + return nil } var usageCmd = &cobra.Command{ @@ -479,7 +470,7 @@ func init() { func resolveVolume(ctx context.Context, c *client.Client, identifier string, allowPrompt bool) (*volume.Volume, error) { trimmed := strings.TrimSpace(identifier) if trimmed == "" { - return nil, fmt.Errorf("volume identifier is required") + return nil, errors.New("volume identifier is required") } resp, err := c.Get(ctx, types.Endpoints.Volume(c.EnvID(), trimmed)) diff --git a/docker/Dockerfile b/docker/Dockerfile index b4fc058bdb..03c04c7e07 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 ARG BUILD_TAGS="" +ARG ARCANE_RUNTIME_BASE_IMAGE=ghcr.io/getarcaneapp/base:distroless # Stage 1: Build Frontend FROM --platform=$BUILDPLATFORM node:25-trixie-slim AS frontend-builder @@ -64,16 +65,15 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ -o /build/arcane \ ./cmd/main.go -# Stage 3: Production Image -FROM debian:trixie-slim AS runner +RUN mkdir -p /build/rootfs/app/data /build/rootfs/builds + +# Stage 3: Hardened Production Image +FROM ${ARCANE_RUNTIME_BASE_IMAGE} AS runner-hardened + ARG TARGETARCH ARG VERSION ARG REVISION -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl tzdata tar gzip libdrm2 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - ENV GIN_MODE=release ENV PORT=3552 ENV ENVIRONMENT=production @@ -86,8 +86,12 @@ ENV NVIDIA_VISIBLE_DEVICES=all \ WORKDIR /app -RUN mkdir -p /app/data /builds -COPY --from=backend-builder /build/arcane . + +# The container starts as "root", but arcane drops to the requetsed PUID and PGID after the runtime is initialized +USER 0:0 +COPY --from=backend-builder --chown=65532:65532 /build/rootfs/app/ /app/ +COPY --from=backend-builder --chown=65532:65532 /build/rootfs/builds/ /builds/ +COPY --from=backend-builder --chown=65532:65532 /build/arcane . EXPOSE 3552 VOLUME ["/app/data"] diff --git a/docker/Dockerfile-agent b/docker/Dockerfile-agent index 2737df7373..6e561bb79e 100644 --- a/docker/Dockerfile-agent +++ b/docker/Dockerfile-agent @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 ARG VERSION="dev" ARG REVISION="unknown" +ARG ARCANE_RUNTIME_BASE_IMAGE=ghcr.io/getarcaneapp/base:trixie FROM --platform=$BUILDPLATFORM golang:1.26.3-trixie AS agent-builder ARG TARGETARCH @@ -43,16 +44,16 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ -o /out/arcane-agent \ ./cmd/main.go -FROM debian:trixie-slim AS agent +RUN mkdir -p /out/rootfs/app/data + +FROM ${ARCANE_RUNTIME_BASE_IMAGE} AS agent ARG TARGETARCH -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl tzdata tar gzip libdrm2 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app -RUN mkdir -p /app/data -COPY --from=agent-builder /out/arcane-agent ./arcane-agent +USER 0:0 +COPY --from=agent-builder --chown=65532:65532 /out/rootfs/app/ /app/ +COPY --from=agent-builder --chown=65532:65532 /out/arcane-agent ./arcane-agent ARG VERSION="dev" ARG REVISION="unknown" @@ -74,4 +75,4 @@ VOLUME ["/app/data"] LABEL com.getarcaneapp.arcane.agent="true" -CMD ["./arcane-agent"] \ No newline at end of file +CMD ["./arcane-agent"] diff --git a/docker/examples/compose.basic.yaml b/docker/examples/compose.basic.yaml index 8b84d82c07..48a8c85d16 100644 --- a/docker/examples/compose.basic.yaml +++ b/docker/examples/compose.basic.yaml @@ -7,11 +7,12 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - arcane-data:/app/data - - /host/path/to/projects:/app/data/projects + - /host/path/to/projects:/host/path/to/projects - /host/path/to/builds:/builds environment: - ENCRYPTION_KEY=xxxxxxxxxxxxxxxxxxxxxx - JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxx + - PROJECTS_DIRECTORY=/host/path/to/projects # Optional: run Arcane as a specific host UID/GID instead of the default runtime user. # When using the mounted Docker socket, Arcane will map the socket group automatically. # - PUID=1000 @@ -25,8 +26,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "curl -fsS http://localhost:3552/api/health >/dev/null || exit 1", + "CMD", + "curl", + "-fsS", + "http://localhost:3552/api/health", ] interval: 10s timeout: 3s diff --git a/docker/examples/compose.proxy.yaml b/docker/examples/compose.proxy.yaml index 8c5793c11d..274798e51a 100644 --- a/docker/examples/compose.proxy.yaml +++ b/docker/examples/compose.proxy.yaml @@ -59,8 +59,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "curl -fsS http://localhost:3552/api/health >/dev/null || exit 1", + "CMD", + "curl", + "-fsS", + "http://localhost:3552/api/health", ] interval: 10s timeout: 3s diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a143749ad5..3dbdf64b6b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -146,6 +146,8 @@ "common_standby": "Standby", "common_offline": "Offline", "common_copy": "Copy", + "common_copied": "Copied to clipboard", + "common_copy_failed": "Failed to copy", "common_copy_json": "Copy JSON", "common_close": "Close", "common_invalid_email": "Must be a valid email", @@ -288,8 +290,10 @@ "common_redeploy_success": "{type} Redeployed Successfully", "common_bulk_remove_success": "Successfully removed {count} {resource}", "common_bulk_remove_failed": "Failed to remove {count} {resource}", + "common_bulk_remove_partial": "Removed {success} of {total} {resource}. {failed} failed.", "common_bulk_delete_success": "Successfully deleted {count} {resource}", "common_bulk_delete_failed": "Failed to delete {count} {resource}", + "common_bulk_delete_partial": "Deleted {success} of {total} {resource}. {failed} failed.", "_comment_common_confirmations": "=== COMMON - CONFIRMATION DIALOGS ===", "common_delete_title": "Delete {resource}", "common_delete_confirm": "Are you sure you want to delete {resource}? This action cannot be undone.", @@ -921,6 +925,9 @@ "swarm_service_form_env_key_placeholder": "KEY", "swarm_service_form_key_placeholder": "key", "swarm_service_form_value_placeholder": "value", + "swarm_node_label_key": "Key", + "swarm_node_label_reserved_prefixes": "Prefixes 'engine.labels' and 'com.docker.swarm' are reserved for system use.", + "swarm_node_label_value": "Value", "swarm_service_form_mount_type_volume": "Volume", "swarm_service_form_mount_type_bind": "Bind", "swarm_service_form_source_placeholder": "source", @@ -1822,6 +1829,7 @@ "git_sync_compose_path": "Compose File Path", "git_sync_sync_files": "Sync Files", "git_sync_sync_files_description": "Copy sibling files from the compose directory into the managed project. When disabled, Arcane only syncs the selected compose file.", + "git_sync_sync_files_locked_hint": "Locked while a pre-deploy hook is configured.", "git_sync_auto_sync": "Auto Sync", "git_sync_sync_interval": "Sync Interval", "git_sync_sync_interval_description": "Minutes", @@ -1852,6 +1860,7 @@ "git_sync_import_open_button": "Import .JSON", "git_sync_browse_files_title": "Browse Repository Files", "git_sync_browse_files_description": "Select a compose file from the repository", + "git_sync_browse_files_description_script": "Select an executable script from the repository", "git_sync_browse_root": "Root", "git_sync_browse_no_files": "No files found", "git_sync_browse_compose_label": "Compose", @@ -1878,6 +1887,32 @@ "git_sync_max_files_per_sync_help": "Maximum files for this sync. Set 0 for no cap.", "git_sync_max_total_size_per_sync_help": "Maximum combined size for this sync. Set 0 for no cap.", "git_sync_max_binary_size_per_sync_help": "Maximum binary file size for this sync. Set 0 for no cap.", + "git_sync_pre_deploy_title": "Pre-deploy script", + "git_sync_pre_deploy_description": "Run a script from this repo before each deploy. Useful for decrypting secrets, generating config, or preparing the deploy environment.", + "git_sync_pre_deploy_script_path_label": "Script path", + "git_sync_pre_deploy_script_path_placeholder": "scripts/pre-deploy.sh", + "git_sync_pre_deploy_script_path_help": "Path within the synced directory (i.e. next to your compose file). The script's first line picks the interpreter.", + "git_sync_pre_deploy_runner_image_label": "Runner image", + "git_sync_pre_deploy_runner_image_placeholder": "alpine:latest", + "git_sync_pre_deploy_runner_image_help": "Docker image used to run the script. Required when a script path is set.", + "git_sync_pre_deploy_timeout_label": "Timeout (seconds)", + "git_sync_pre_deploy_timeout_help": "Maximum time the script is allowed to run.", + "git_sync_pre_deploy_network_mode_label": "Network", + "git_sync_pre_deploy_network_mode_placeholder": "none", + "git_sync_pre_deploy_network_mode_help": "Network the script can reach. Default \"none\" blocks all network access. Use \"bridge\" for outbound internet, or a Docker network name.", + "git_sync_pre_deploy_env_label": "Environment variables", + "git_sync_pre_deploy_env_placeholder": "# Example:\nFOO=bar\nDB_HOST=db.internal", + "git_sync_pre_deploy_env_help": "Env vars the script can read, one KEY=VALUE per line. Same format as a .env file.", + "git_sync_pre_deploy_extra_mounts_label": "Extra mounts", + "git_sync_pre_deploy_extra_mounts_placeholder": "# Example:\n/host/path:/container/path:ro", + "git_sync_pre_deploy_extra_mounts_help": "Host bind mounts added to the runner container, one per line in host_path:container_path[:ro|:rw] form.", + "git_sync_pre_deploy_acknowledgement": "Arcane runs this script on every deploy. Anyone who can push to the repo can change what it does.", + "git_sync_pre_deploy_managed_hint": "Managed by administrators", + "git_sync_pre_deploy_status_configured": "Configured", + "git_sync_pre_deploy_status_none": "Not configured", + "lifecycle_indicator_tooltip": "Runs a pre-deploy script: {path}", + "lifecycle_inline_label": "Pre-deploy script", + "lifecycle_inline_runner_summary": "(image: {image}, network: {network})", "common_syncing": "Syncing...", "_comment_customize_variables": "=== CUSTOMIZATION - VARIABLES ===", "variables_title": "Variables", @@ -1954,12 +1989,40 @@ "docker_info_ipv4_forwarding": "IPv4 Forwarding", "docker_info_http_proxy": "HTTP Proxy", "docker_info_https_proxy": "HTTPS Proxy", - "docker_info_no_proxy": "No Proxy", - "docker_info_bridge_ip": "Bridge IP", - "docker_info_plugins_section": "Plugins", + "docker_info_no_proxy": "No Proxy", "docker_info_plugins_section": "Plugins", "docker_info_logs_plugin": "Logs", "docker_info_security_options": "Security Options", "docker_info_runtimes": "Runtimes", + "docker_info_warnings_section": "Warnings", + "docker_info_capabilities_section": "Runtime Capabilities", + "docker_info_storage_details_section": "Storage Driver Details", + "docker_info_swarm_section": "Swarm", + "docker_info_labels_section": "Engine Labels", + "docker_info_os_version_label": "OS Version", + "docker_info_debug_label": "Debug Mode", + "docker_info_live_restore_label": "Live Restore", + "docker_info_index_server_label": "Index Server", + "docker_info_product_license_label": "Product License", + "docker_info_containerd_commit_label": "containerd", + "docker_info_runc_commit_label": "runc", + "docker_info_init_commit_label": "init", + "docker_info_memory_limit_label": "Memory Limit", + "docker_info_swap_limit_label": "Swap Limit", + "docker_info_kernel_memory_tcp_label": "Kernel Memory TCP", + "docker_info_cpu_cfs_period_label": "CPU CFS Period", + "docker_info_cpu_cfs_quota_label": "CPU CFS Quota", + "docker_info_cpu_shares_label": "CPU Shares", + "docker_info_cpu_set_label": "CPUSet", + "docker_info_pids_limit_label": "PIDs Limit", + "docker_info_oom_kill_disable_label": "OOM Kill Disable", + "docker_info_event_listeners_label": "Event Listeners", + "docker_info_address_pools_label": "Address Pools", + "docker_info_authorization_plugin": "Authorization", + "docker_info_swarm_state_label": "Node State", + "docker_info_swarm_node_id_label": "Node ID", + "docker_info_swarm_manager_label": "Is Manager", + "docker_info_swarm_managers_label": "Managers", + "docker_info_swarm_nodes_label": "Nodes", "_comment_sidebar": "=== SIDEBAR ===", "sidebar_environment_label": "Environment", "sidebar_select_environment": "Select Environment", @@ -1970,6 +2033,85 @@ "sidebar_version": "{version}", "sidebar_update_available": "Update available", "sidebar_update_available_tooltip": "Update available: {version}", + "_comment_activity_center": "=== ACTIVITY CENTER ===", + "activity_center_title": "Activity Center", + "activity_center_open": "Open activity center", + "activity_active_count": "{count} active", + "activity_count_many": "9+", + "activity_filter_completed": "Completed", + "activity_status_queued": "Queued", + "activity_status_cancelled": "Cancelled", + "activity_type_image_pull": "Image Pull", + "activity_type_image_build": "Image Build", + "activity_type_image_update_check": "Image Update Check", + "activity_type_project_pull": "Project Pull", + "activity_type_project_build": "Project Build", + "activity_type_project_deploy": "Project Deploy", + "activity_type_project_redeploy": "Project Redeploy", + "activity_type_project_down": "Project Down", + "activity_type_project_restart": "Project Restart", + "activity_type_project_destroy": "Project Destroy", + "activity_type_container_start": "Container Start", + "activity_type_container_stop": "Container Stop", + "activity_type_container_restart": "Container Restart", + "activity_type_container_redeploy": "Container Redeploy", + "activity_type_container_delete": "Container Delete", + "activity_type_vulnerability_scan": "Vulnerability Scan", + "activity_type_auto_update": "Auto Update", + "activity_type_system_prune": "System Prune", + "activity_type_resource_action": "Resource Action", + "activity_unknown_target": "Unknown target", + "activity_no_message": "No message yet", + "activity_progress_percent": "{progress}%", + "activity_empty_title": "No activity here", + "activity_empty_description": "Background work will appear here as it starts.", + "activity_step_unknown": "Waiting for the next step", + "activity_duration": "Duration", + "activity_duration_ms": "{ms}ms", + "activity_duration_seconds": "{seconds}s", + "activity_duration_minutes": "{minutes}m {seconds}s", + "activity_output_title": "Output", + "activity_copy_output": "Copy Output", + "activity_output_loading": "Loading output…", + "activity_output_load_failed": "Failed to load activity output", + "activity_output_empty": "No output has been recorded for this activity yet.", + "activity_stream_error": "Lost connection to activity stream", + "activity_select_prompt_title": "Select an activity", + "activity_select_prompt": "Choose a background task to inspect its progress and recent output.", + "activity_view_activity": "View Activity", + "activity_source_environment": "Source environment", + "activity_started_by": "Started by {user}", + "activity_started_by_label": "Started by", + "activity_clear_history": "Clear history", + "activity_clear_history_title": "Clear activity history?", + "activity_clear_history_message": "Completed, failed, and cancelled entries for every configured environment will be deleted. Running activity will stay visible.", + "activity_clear_history_confirm": "Clear History", + "activity_clear_history_success": "Activity history cleared", + "activity_clear_history_partial": "Activity history partially cleared. Succeeded for {count}. Failed for {environments}.", + "activity_clear_history_failed": "Failed to clear activity history", + "activity_environment_load_failed": "Could not load activity from {environment}", + "activity_cancel": "Cancel activity", + "activity_cancel_title": "Cancel activity?", + "activity_cancel_message": "This requests cancellation of the running operation. Work already completed by Docker may not be rolled back.", + "activity_cancel_confirm": "Cancel Activity", + "activity_cancel_success": "Cancellation requested", + "activity_cancel_failed": "Failed to cancel activity", + "activity_cancel_already_finished": "Activity already finished", + "activity_settings_title": "Activity", + "activity_settings_description": "Configure Activity Center history retention for each environment.", + "activity_settings_saved": "Activity settings saved", + "activity_history_section_title": "History Retention", + "activity_history_retention_days": "Retention Days", + "activity_history_retention_days_description": "Delete completed Activity Center entries older than this many days.", + "activity_history_retention_days_placeholder": "30", + "activity_history_retention_days_help": "Set to 0 to disable age-based cleanup.", + "activity_history_max_entries": "Maximum Entries", + "activity_history_max_entries_description": "Keep this many completed Activity Center entries per environment.", + "activity_history_max_entries_placeholder": "1000", + "activity_history_max_entries_help": "Set to 0 to disable count-based cleanup.", + "activity_scan_phase_creating_container": "Creating container", + "activity_scan_phase_scanning_image": "Scanning image", + "activity_scan_phase_storing_results": "Storing results", "_comment_version_info": "=== VERSION INFO DIALOG ===", "version_info_title": "About Arcane", "version_info_version": "Version", @@ -2050,13 +2192,11 @@ "oidc_issuer_url_description": "The issuer URL will be used to auto-discover OIDC endpoints.", "oidc_scopes_label": "Scopes", "oidc_scopes_placeholder": "openid email profile", - "oidc_admin_claim_label": "Admin Claim", - "oidc_admin_value_label": "Required Value(s)", - "oidc_admin_role_mapping_title": "Admin Role Mapping", - "oidc_admin_role_mapping_description": "Map an OIDC claim to grant the admin role. Examples: roles=admin, groups=admin, or admin=true.", - "oidc_admin_claim_placeholder": "e.g., roles, groups, realm_access.roles, admin", - "oidc_admin_value_placeholder": "e.g., admin (comma-separated)", - "oidc_admin_value_help": "Leave empty for boolean claims (admin=true).", + "oidc_groups_claim_label": "Groups Claim", + "oidc_groups_claim_placeholder": "groups", + "oidc_groups_claim_help": "JSON path to the claim that contains the user's groups. Default: groups. Examples: groups, realm_access.roles, memberOf.", + "oidc_role_mappings_title": "Role Mappings", + "oidc_role_mappings_description": "Map claim values to roles assigned on login. Manual role assignments are preserved across logins.", "oidc_skip_tls_verify_label": "Skip TLS Verification", "oidc_skip_tls_verify_description": "Disable TLS certificate verification for OIDC endpoints. Proceed with CAUTION.", "oidc_auto_redirect_label": "Auto Redirect to Provider", @@ -2172,7 +2312,6 @@ "notifications_title": "Notifications", "notifications_description": "Configure notifications for container updates", "notifications_tab_built_in": "Built-in Notifications", - "notifications_tab_apprise": "Apprise", "notifications_read_only_title": "Read-only Mode", "notifications_read_only_description": "Settings are read-only in this environment. Configuration changes are disabled.", "notifications_discord_title": "Discord", @@ -2242,18 +2381,6 @@ "notifications_test_vulnerability_notification": "Test Vulnerability Notification", "notifications_test_prune_report_notification": "Test Prune Report Notification", "notifications_test_auto_heal_notification": "Test Auto-Heal Notification", - "notifications_apprise_title": "Apprise Integration", - "notifications_apprise_description": "Configure Apprise API for unified notifications across multiple services", - "notifications_apprise_enabled_label": "Enable Apprise", - "notifications_apprise_api_url_label": "Apprise API URL", - "notifications_apprise_api_url_placeholder": "http://localhost:8000/notify", - "notifications_apprise_api_url_help": "URL of your Apprise API endpoint", - "notifications_apprise_image_tag_label": "Image Update Tag", - "notifications_apprise_image_tag_placeholder": "image-updates", - "notifications_apprise_image_tag_help": "Optional tag for image update notifications", - "notifications_apprise_container_tag_label": "Container Update Tag", - "notifications_apprise_container_tag_placeholder": "container-updates", - "notifications_apprise_container_tag_help": "Optional tag for container update notifications", "notifications_signal_title": "Signal Notifications", "notifications_signal_description": "Send notifications via Signal Messenger through a Signal API server", "notifications_signal_enabled_label": "Enable Signal Notifications", @@ -2514,10 +2641,71 @@ "api_key_delete_success": "API key \"{name}\" deleted successfully", "api_key_static_title": "Static Admin API Key", "api_key_static_description": "This API key is managed by ADMIN_STATIC_API_KEY and cannot be edited or deleted from the UI.", + "api_key_bootstrap_title": "Environment Bootstrap API Key", + "api_key_bootstrap_description": "This API key is auto-generated for the agent pairing flow and cannot be edited or deleted from the UI.", + "api_key_bootstrap_locked_description": "This is an auto-generated key used by the agent pairing flow for an environment. It's locked from manual edits — modifying its permissions or deleting it would break the paired edge agent.", "api_key_bulk_delete_success": "Successfully deleted {count} API key(s)", "api_key_bulk_delete_failed": "Failed to delete {count} API key(s)", "api_key_bulk_delete_static_skipped": "Skipped {count} static API key(s). Only deletable keys will be removed.", "api_key_bulk_delete_static_only": "Static API keys cannot be deleted.", + "_comment_federated_credentials": "=== FEDERATED CREDENTIALS ===", + "federated_credential_page_title": "Federated Credentials", + "federated_credential_page_description": "Trust external OIDC workload identities and exchange them for short-lived Arcane tokens.", + "federated_credential_create_title": "Create Federated Credential", + "federated_credential_edit_title": "Edit Federated Credential", + "federated_credential_create_description": "Create a trust rule for a CI or workload OIDC identity.", + "federated_credential_edit_description": "Update the trust rule \"{name}\"", + "federated_credential_create_button": "Create Federated Credential", + "federated_credential_created_success": "Federated credential \"{name}\" created successfully", + "federated_credential_create_failed": "Failed to create federated credential \"{name}\"", + "federated_credential_updated_success": "Federated credential \"{name}\" updated successfully", + "federated_credential_update_failed": "Failed to update federated credential \"{name}\"", + "federated_credential_delete_title": "Delete Federated Credential \"{name}\"?", + "federated_credential_delete_message": "Are you sure you want to delete the federated credential \"{name}\"? This action cannot be undone.", + "federated_credential_delete_selected_title": "Delete {count} Federated Credential(s)?", + "federated_credential_delete_selected_message": "Are you sure you want to delete {count} federated credential(s)? This action cannot be undone.", + "federated_credential_delete_failed": "Failed to delete federated credential \"{name}\"", + "federated_credential_delete_success": "Federated credential \"{name}\" deleted successfully", + "federated_credential_bulk_delete_success": "Successfully deleted {count} federated credential(s)", + "federated_credential_bulk_delete_failed": "Failed to delete {count} federated credential(s)", + "federated_credential_name_label": "Name", + "federated_credential_name_placeholder": "GitHub Actions production deploy", + "federated_credential_description_placeholder": "Optional description", + "federated_credential_issuer_label": "Issuer", + "federated_credential_issuer_placeholder": "https://token.actions.githubusercontent.com", + "federated_credential_issuer_description": "The HTTPS OIDC issuer that signs the workload token.", + "federated_credential_issuer_required": "Enter a valid issuer URL", + "federated_credential_audiences_label": "Audiences", + "federated_credential_audiences_placeholder": "https://arcane.example.com", + "federated_credential_audiences_description": "One audience per line or comma. The external token must contain one of these audiences.", + "federated_credential_audience_required": "Enter at least one audience", + "federated_credential_subject_claim_label": "Subject Claim", + "federated_credential_subject_claim_placeholder": "sub", + "federated_credential_subject_match_label": "Subject Match", + "federated_credential_subject_match_placeholder": "repo:owner/repo:ref:refs/heads/main", + "federated_credential_subject_match_description": "Exact subject value or an anchored glob pattern.", + "federated_credential_subject_required": "Enter a subject match", + "federated_credential_match_type_label": "Match Type", + "federated_credential_match_exact": "Exact", + "federated_credential_match_glob": "Glob", + "federated_credential_role_required": "Select a role", + "federated_credential_role_scope_column": "Role / Scope", + "federated_credential_scope_global_option": "Global", + "federated_credential_ttl_label": "Token TTL", + "federated_credential_ttl_description": "Issued Arcane token lifetime in seconds.", + "federated_credential_ttl_min": "Token TTL must be at least 60 seconds", + "federated_credential_ttl_max": "Token TTL cannot exceed 3600 seconds", + "federated_credential_expires_at_label": "Credential Expires", + "federated_credential_expires_at_description": "Optional date when this trust rule stops accepting exchanges.", + "federated_credential_enabled_label": "Enabled", + "federated_credential_enabled_description": "Allow matching workloads to exchange tokens with this credential.", + "federated_credential_status_expired": "Expired", + "federated_credential_last_used": "Last Used", + "federated_credential_wildcard_warning_title": "Broad subject match", + "federated_credential_wildcard_warning_description": "Use the narrowest subject pattern that fits the workload.", + "federated_credential_instructions_title": "Federated Credential Created", + "federated_credential_instructions_description": "Use the Arcane CLI to request an external OIDC token and exchange it during automation.", + "federated_credential_instructions_snippet_label": "GitHub Actions snippet", "common_done": "Done", "common_important": "Important", "select_a_date": "Select a date", @@ -2787,5 +2975,175 @@ "webhook_disable_failed": "Failed to disable webhook \"{name}\"", "webhook_status_enabled": "Enabled", "webhook_status_disabled": "Disabled", - "webhook_col_status": "Status" + "webhook_col_status": "Status", + "resource_role": "role", + "resource_role_cap": "Role", + "resource_oidc_mapping": "mapping", + "resource_oidc_mapping_cap": "Mapping", + "_comment_roles": "=== ROLES & RBAC ===", + "roles_title": "Roles", + "roles_subtitle": "Define what users and API keys can do", + "roles_create_title": "Create role", + "roles_edit_title": "Edit role", + "roles_delete_message": "Delete role \"{name}\"? This removes every assignment of it.", + "roles_save_changes": "Save changes", + "roles_clone_button": "Clone as custom role", + "roles_clone_success": "Cloned to \"{name}\"", + "roles_clone_failed": "Failed to clone role", + "roles_built_in": "Built-in", + "roles_built_in_note": "Built-in roles cannot be edited or deleted. Use Clone to start from this set of permissions.", + "roles_custom": "Custom", + "roles_name_label": "Name", + "roles_name_placeholder": "e.g. Deploy Bot", + "roles_name_required": "Name is required", + "roles_description_label": "Description", + "roles_description_placeholder": "What is this role for?", + "roles_permissions_label": "Permissions", + "roles_permissions_required": "Pick at least one permission", + "roles_permissions_count": "{count} of {total} permissions", + "roles_col_type": "Type", + "roles_col_assigned_users": "Assigned users", + "roles_col_permissions": "Permissions", + "roles_assigned_users_count": "{count} users", + "_comment_permissions": "=== PERMISSION PICKER ===", + "permissions_group_label": "{resource} ({selected}/{total})", + "permissions_select_all": "Select all", + "permissions_search_placeholder": "Filter permissions…", + "permissions_no_matches": "No permissions match this search.", + "permissions_scope_global": "Global", + "permissions_scope_env": "Per-environment", + "_comment_oidc_mappings": "=== OIDC ROLE MAPPINGS ===", + "oidc_mappings_title": "OIDC Mappings", + "oidc_mappings_subtitle": "Map OIDC group claims to roles assigned on login", + "oidc_mappings_create_title": "Add OIDC mapping", + "oidc_mappings_edit_title": "Edit OIDC mapping", + "oidc_mappings_delete_message": "Delete mapping for claim \"{claim}\"? Users logged in through this claim will lose their assigned role on next login.", + "oidc_mappings_col_claim": "Claim value", + "oidc_mappings_col_scope": "Environment", + "oidc_mappings_claim_label": "Claim value", + "oidc_mappings_claim_placeholder": "e.g. docker-admins", + "oidc_mappings_claim_required": "Claim value is required", + "oidc_mappings_role_label": "Role", + "oidc_mappings_role_required": "Role is required", + "oidc_mappings_scope_label": "Environment scope", + "oidc_mappings_scope_global_option": "Global (org-wide)", + "oidc_mappings_empty_title": "No OIDC mappings yet", + "oidc_mappings_empty_body": "When OIDC users log in, Arcane checks their group claim against these mappings to assign roles. Configure the claim under Authentication settings.", + "_comment_user_role_assignments": "=== USER ROLE ASSIGNMENTS ===", + "users_role_assignments_label": "Role assignments", + "users_role_assignments_description": "Pick the role this user holds on each environment. Add a Global assignment for org-wide permissions.", + "users_role_assignments_required": "Pick at least one role assignment", + "users_role_assignments_add": "Add assignment", + "users_role_assignments_remove": "Remove", + "users_role_assignments_environment": "Environment", + "users_role_assignments_role": "Role", + "users_role_assignments_scope_global": "Global (org-wide)", + "users_role_assignments_oidc_managed": "Role assignments for this user are managed by OIDC group mappings. Manual changes will be overwritten on next login.", + "users_role_assignments_oidc_link": "Manage OIDC mappings", + "users_role_summary_none": "No access", + "users_role_summary_env_count": "Assigned on {count} environments", + "_comment_api_key_permissions": "=== API KEY PERMISSIONS ===", + "api_key_permissions_description": "Choose the permissions this key may use. You cannot grant a permission you do not hold yourself.", + "api_key_permissions_required": "Pick at least one permission", + "api_key_scope_all": "All permissions", + "api_key_scope_role": "Matches {role}", + "api_key_scope_count": "{count} {count, plural, one {permission} other {permissions}}", + "_comment_rbac_migration": "=== RBAC MIGRATION BANNER ===", + "rbac_migration_banner_title": "Welcome to RBAC", + "rbac_migration_banner_body": "Your access was migrated to Viewer on all environments. Admins can promote users from Settings → Users.", + "rbac_migration_banner_dismiss": "Dismiss", + "_comment_diagnostics": "=== DIAGNOSTICS ===", + "diagnostics_title": "Diagnostics", + "diagnostics_description": "Live Go runtime, profiling, and backend logs.", + "diagnostics_status_live": "Live", + "diagnostics_status_paused": "Paused", + "diagnostics_status_connecting": "Connecting…", + "diagnostics_updated_ago": "updated {ago}", + "diagnostics_just_now": "just now", + "diagnostics_seconds_ago": "{seconds}s ago", + "diagnostics_pause": "Pause", + "diagnostics_resume": "Resume", + "diagnostics_refresh": "Refresh", + "diagnostics_loading": "Loading diagnostics…", + "diagnostics_error_load": "Failed to load diagnostics", + "diagnostics_error_download": "Download failed", + "diagnostics_error_dump": "Failed to load dump", + "diagnostics_stat_goroutines": "Goroutines", + "diagnostics_stat_heap_alloc": "Heap Alloc", + "diagnostics_stat_ws_conns": "WS Conns", + "diagnostics_stat_gc_cycles": "GC Cycles", + "diagnostics_stat_cpu_procs": "CPU / Procs", + "diagnostics_stat_uptime": "Uptime", + "diagnostics_section_runtime": "Runtime", + "diagnostics_runtime_go_version": "Go version", + "diagnostics_runtime_platform": "Platform", + "diagnostics_runtime_gomaxprocs": "GOMAXPROCS", + "diagnostics_runtime_num_cpu": "Logical CPUs", + "diagnostics_runtime_goroutines": "Goroutines", + "diagnostics_runtime_ws_workers": "WS worker goroutines", + "diagnostics_runtime_cgo_calls": "Cgo calls", + "diagnostics_runtime_uptime": "Uptime", + "diagnostics_section_memory": "Memory / Heap", + "diagnostics_mem_in_use": "In use", + "diagnostics_mem_idle": "Idle", + "diagnostics_mem_released": "Released", + "diagnostics_mem_heap_alloc": "Heap alloc", + "diagnostics_mem_heap_sys": "Heap sys", + "diagnostics_mem_heap_objects": "Heap objects", + "diagnostics_mem_stack_in_use": "Stack in use", + "diagnostics_mem_total_alloc": "Total alloc (cumulative)", + "diagnostics_mem_sys_total": "Sys (total)", + "diagnostics_mem_next_gc": "Next GC target", + "diagnostics_mem_gc_cpu_fraction": "GC CPU fraction", + "diagnostics_section_gc": "Garbage Collector", + "diagnostics_gc_total_cycles": "Total cycles", + "diagnostics_gc_forced_cycles": "Forced cycles", + "diagnostics_gc_total_pause": "Total pause", + "diagnostics_gc_last": "Last GC", + "diagnostics_gc_recent_pauses": "Recent pauses (newest left)", + "diagnostics_section_websocket": "WebSocket Connections", + "diagnostics_ws_project_logs": "Project logs", + "diagnostics_ws_container_logs": "Container logs", + "diagnostics_ws_container_stats": "Container stats", + "diagnostics_ws_terminals": "Terminals", + "diagnostics_ws_system_stats": "System stats", + "diagnostics_ws_service_logs": "Service logs", + "diagnostics_ws_kind_terminal": "Terminal", + "diagnostics_section_connections": "Active Connections ({count})", + "diagnostics_conn_kind": "Kind", + "diagnostics_conn_resource": "Resource", + "diagnostics_conn_client_ip": "Client IP", + "diagnostics_conn_user": "User", + "diagnostics_conn_since": "Since", + "diagnostics_section_logs": "Live Backend Logs", + "diagnostics_logs_streaming": "Streaming", + "diagnostics_logs_disconnected": "Disconnected", + "diagnostics_logs_count": "{count} lines", + "diagnostics_logs_all_levels": "All levels", + "diagnostics_logs_level_error": "Error", + "diagnostics_logs_level_warn": "Warn", + "diagnostics_logs_level_info": "Info", + "diagnostics_logs_level_debug": "Debug", + "diagnostics_logs_filter_placeholder": "Filter…", + "diagnostics_logs_auto_scroll": "Auto-scroll", + "diagnostics_logs_clear": "Clear", + "diagnostics_logs_empty": "No log entries.", + "diagnostics_section_dumps": "Inline Dumps", + "diagnostics_dump_goroutine": "Goroutine stacks", + "diagnostics_dump_heap": "Heap profile", + "diagnostics_dump_loading": "Loading…", + "diagnostics_dump_empty": "No data.", + "diagnostics_section_profiles": "Download pprof Profiles", + "diagnostics_profiles_hint": "Open the downloaded files with go tool pprof / go tool trace.", + "diagnostics_profile_heap": "Heap", + "diagnostics_profile_goroutine": "Goroutine", + "diagnostics_profile_allocs": "Allocs", + "diagnostics_profile_block": "Block", + "diagnostics_profile_mutex": "Mutex", + "diagnostics_profile_threadcreate": "Threads", + "diagnostics_profile_cpu": "CPU (30s)", + "diagnostics_profile_trace": "Trace (5s)", + "_comment_no_access": "=== NO ACCESS PAGE ===", + "no_access_page_title": "You don't have access to anything yet", + "no_access_page_body": "Your Arcane administrator hasn't assigned you any role permissions. Ask them to add a role to your account from Settings → Users." } diff --git a/frontend/package.json b/frontend/package.json index 098296e09e..482bcab401 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ "@codemirror/merge": "^6.12.1", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", + "@fontsource-variable/geist-mono": "^5.2.8", + "@fontsource-variable/montserrat": "^5.2.8", "@lezer/highlight": "^1.2.3", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.61.1", diff --git a/frontend/project.inlang/cache/plugins/2sy648wh9sugi b/frontend/project.inlang/cache/plugins/2sy648wh9sugi index 1f9c4e487b..3c313baea2 100644 --- a/frontend/project.inlang/cache/plugins/2sy648wh9sugi +++ b/frontend/project.inlang/cache/plugins/2sy648wh9sugi @@ -2008,28 +2008,34 @@ function parsePattern2(value) { continue; } if (char === "{") { - let variableName = ""; - let closingIndex = -1; - for (let cursor = index + 1; cursor < value.length; cursor += 1) { - const current = value[cursor]; - if (current === "}") { - closingIndex = cursor; - break; - } - variableName += current; - } + const closingIndex = findPlaceholderClosingIndex(value, index); if (closingIndex === -1) { buffer += char; continue; } + const placeholder = value.slice(index + 1, closingIndex); + const markupNode = parseMarkupPlaceholder(placeholder); flushBuffer(); + if (markupNode) { + for (const option of markupNode.options ?? []) { + if (option.value.type === "variable-reference") { + declarations.push({ + type: "input-variable", + name: option.value.name + }); + } + } + pattern.push(markupNode); + index = closingIndex; + continue; + } declarations.push({ type: "input-variable", - name: variableName + name: placeholder }); pattern.push({ type: "expression", - arg: { type: "variable-reference", name: variableName } + arg: { type: "variable-reference", name: placeholder } }); index = closingIndex; continue; @@ -2042,6 +2048,176 @@ function parsePattern2(value) { pattern }; } +function findPlaceholderClosingIndex(value, openingIndex) { + let inQuotedLiteral = false; + for (let cursor = openingIndex + 1; cursor < value.length; cursor += 1) { + const current = value[cursor]; + if (inQuotedLiteral && current === "\\") { + cursor += 1; + continue; + } + if (current === "|") { + inQuotedLiteral = !inQuotedLiteral; + continue; + } + if (current === "}" && inQuotedLiteral === false) { + return cursor; + } + } + return -1; +} +function parseMarkupPlaceholder(placeholder) { + if (placeholder.startsWith("#")) { + const parsed = parseMarkupBody(placeholder.slice(1), true); + if (!parsed) { + throw new Error(`Invalid markup placeholder: {${placeholder}}`); + } + const { name, options, attributes, standalone } = parsed; + return { + type: standalone ? "markup-standalone" : "markup-start", + name, + ...options.length > 0 ? { options } : {}, + ...attributes.length > 0 ? { attributes } : {} + }; + } + if (placeholder.startsWith("/")) { + const parsed = parseMarkupBody(placeholder.slice(1), false); + if (!parsed) { + throw new Error(`Invalid markup placeholder: {${placeholder}}`); + } + const { name, options, attributes } = parsed; + return { + type: "markup-end", + name, + ...options.length > 0 ? { options } : {}, + ...attributes.length > 0 ? { attributes } : {} + }; + } + return void 0; +} +function parseMarkupBody(body, allowStandalone) { + let index = 0; + const options = []; + const attributes = []; + let standalone = false; + index = skipWhitespace(body, index); + const nameToken = readNameToken(body, index); + if (!nameToken) return void 0; + const name = nameToken.value; + index = nameToken.nextIndex; + while (index < body.length) { + index = skipWhitespace(body, index); + if (index >= body.length) break; + if (allowStandalone && body[index] === "/") { + const trailing = body.slice(index + 1).trim(); + if (trailing.length > 0) return void 0; + standalone = true; + index = body.length; + break; + } + if (body[index] === "@") { + index += 1; + const attributeName = readIdentifier(body, index); + if (!attributeName) return void 0; + index = attributeName.nextIndex; + index = skipWhitespace(body, index); + if (body[index] === "=") { + index += 1; + index = skipWhitespace(body, index); + const attributeValue = parseMarkupValue(body, index); + if (!attributeValue) return void 0; + if (attributeValue.value.type === "variable-reference") { + return void 0; + } + attributes.push({ + name: attributeName.value, + value: attributeValue.value + }); + index = attributeValue.nextIndex; + } else { + attributes.push({ name: attributeName.value, value: true }); + } + continue; + } + const optionName = readIdentifier(body, index); + if (!optionName) return void 0; + index = optionName.nextIndex; + index = skipWhitespace(body, index); + if (body[index] !== "=") return void 0; + index += 1; + index = skipWhitespace(body, index); + const optionValue = parseMarkupValue(body, index); + if (!optionValue) return void 0; + options.push({ + name: optionName.value, + value: optionValue.value + }); + index = optionValue.nextIndex; + } + return { name, options, attributes, standalone }; +} +function skipWhitespace(value, index) { + while (index < value.length && /\s/.test(value[index])) { + index += 1; + } + return index; +} +function readNameToken(value, index) { + return readIdentifier(value, index); +} +function readIdentifier(value, index) { + let cursor = index; + while (cursor < value.length && /\s/.test(value[cursor]) === false && value[cursor] !== "=" && value[cursor] !== "/" && value[cursor] !== "@") { + cursor += 1; + } + const parsed = value.slice(index, cursor); + if (parsed.length === 0) return void 0; + return { value: parsed, nextIndex: cursor }; +} +function parseMarkupValue(value, index) { + if (index >= value.length) return void 0; + if (value[index] === "|") { + let cursor = index + 1; + let literal2 = ""; + while (cursor < value.length) { + const char = value[cursor]; + if (char === "\\") { + const next = value[cursor + 1]; + if (next === "|" || next === "\\" || next === "}") { + literal2 += next; + cursor += 2; + continue; + } + literal2 += char; + cursor += 1; + continue; + } + if (char === "|") { + return { + value: { type: "literal", value: literal2 }, + nextIndex: cursor + 1 + }; + } + literal2 += char; + cursor += 1; + } + return void 0; + } + if (value[index] === "$") { + const variable = readIdentifier(value, index + 1); + if (!variable) return void 0; + return { + value: { type: "variable-reference", name: variable.value }, + nextIndex: variable.nextIndex + }; + } + const literal = readIdentifier(value, index); + if (!literal) return void 0; + return { + value: { type: "literal", value: literal.value }, + nextIndex: literal.nextIndex + }; +} function parseMatches(value) { const stripped = value.replace(" ", ""); const matches = []; @@ -2083,13 +2259,26 @@ function parseDeclaration(value) { } else if (value.startsWith("local")) { const match = value.match(/local (\w+) = (\w+): (\w+)(.*)/); const [, name, ref, fn, optionsString] = match; - const options = optionsString?.trim().split(/\s+/).map((pair) => { - const [key, value2] = pair.split("="); - return key && value2 ? { - name: key, - value: { type: "literal", value: value2 } - } : null; - }).filter(Boolean); + const options = []; + for (const optionMatch of (optionsString ?? "").matchAll( + /(\w+)\s*=\s*([^\s]+)/g + )) { + const optionName = optionMatch[1]; + const optionValue = optionMatch[2]; + if (!optionName || !optionValue) { + continue; + } + options.push({ + name: optionName, + value: optionValue.startsWith("$") && optionValue.length > 1 ? { + type: "variable-reference", + name: optionValue.slice(1) + } : { + type: "literal", + value: optionValue + } + }); + } return { type: "local-variable", name: name.trim(), @@ -2219,12 +2408,45 @@ function serializeVariants(bundle, message, variants) { function serializePattern(pattern) { let result = ""; for (const part of pattern) { - if (part.type === "text") { - result += escapePatternText(part.value); - } else if (part.arg.type === "variable-reference") { - result += `{${part.arg.name}}`; - } else { - throw new Error("Unsupported expression type"); + switch (part.type) { + case "text": + result += escapePatternText(part.value); + break; + case "expression": + if (part.arg.type === "variable-reference") { + result += `{${part.arg.name}}`; + break; + } + throw new Error("Unsupported expression type"); + case "markup-start": + result += serializeMarkup( + "#", + part.name, + part.options, + part.attributes, + false + ); + break; + case "markup-end": + result += serializeMarkup( + "/", + part.name, + part.options, + part.attributes, + false + ); + break; + case "markup-standalone": + result += serializeMarkup( + "#", + part.name, + part.options, + part.attributes, + true + ); + break; + default: + throw new Error("Unsupported pattern element type"); } } return result; @@ -2232,6 +2454,31 @@ function serializePattern(pattern) { function escapePatternText(value) { return value.replace(/\\/g, "\\\\").replace(/{/g, "\\{").replace(/}/g, "\\}"); } +function serializeMarkup(prefix, name, options, attributes, standalone) { + const serializedOptions = (options ?? []).map((option) => { + if (option.value.type === "variable-reference") { + return `${option.name}=$${option.value.name}`; + } + return `${option.name}=|${escapeMarkupLiteral(option.value.value)}|`; + }); + const serializedAttributes = (attributes ?? []).map((attribute) => { + if (attribute.value === true) { + return `@${attribute.name}`; + } + return `@${attribute.name}=|${escapeMarkupLiteral(attribute.value.value)}|`; + }); + const metadata = [...serializedOptions, ...serializedAttributes].join(" "); + if (metadata.length === 0) { + return standalone ? `{${prefix}${name}/}` : `{${prefix}${name}}`; + } + if (standalone) { + return `{${prefix}${name} ${metadata}/}`; + } + return `{${prefix}${name} ${metadata}}`; +} +function escapeMarkupLiteral(value) { + return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/}/g, "\\}"); +} function serializeMatcher(matches) { const parts = matches.sort((a, b) => a.key.localeCompare(b.key)).map( (match) => match.type === "literal-match" ? `${match.key}=${match.value}` : `${match.key}=*` @@ -2253,10 +2500,15 @@ function serializeDeclaration(declaration) { } if (declaration.value.annotation?.options) { for (const option of declaration.value?.annotation?.options ?? []) { - if (option.value.type !== "literal") { - throw new Error("Unsupported option type"); + if (option.value.type === "literal") { + result += ` ${option.name}=${option.value.value}`; + continue; + } + if (option.value.type === "variable-reference") { + result += ` ${option.name}=$${option.value.name}`; + continue; } - result += ` ${option.name}=${option.value.value}`; + throw new Error("Unsupported option type"); } } return result; diff --git a/frontend/src/app.css b/frontend/src/app.css index 296dd55a20..56c7738093 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,67 +1,43 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@import '@fontsource-variable/montserrat'; +@import '@fontsource-variable/geist-mono'; -@variant dark (&:where(.dark, .dark *)); - -@font-face { - font-family: 'Mona Sans'; - src: url('/api/fonts/sans') format('woff2'); - font-weight: 200 900; - font-display: swap; - font-style: normal; -} - -@font-face { - font-family: 'Mona Sans Mono'; - src: url('/api/fonts/mono') format('woff2'); - font-weight: 200 900; - font-display: swap; - font-style: normal; -} - -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } -} +@custom-variant dark (&:is(.dark *)); /* BASE THEMES */ :root { - --radius: 0.65rem; + --radius: 0.6rem; --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0 / 0.6); - --card-foreground: oklch(0.141 0.005 285.823); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); + --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.606 0.25 292.717); - --primary-foreground: oklch(0.141 0.005 285.823); - --secondary: oklch(0.967 0.001 286.375 / 0.6); + --primary-foreground: oklch(0.969 0.016 293.756); + --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375 / 0.6); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375 / 0.6); - --accent-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); --ring: oklch(0.606 0.25 292.717); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0 / 0.6); - --sidebar-foreground: oklch(0.141 0.005 285.823); + --chart-1: oklch(0.811 0.111 293.571); + --chart-2: oklch(0.606 0.25 292.717); + --chart-3: oklch(0.541 0.281 293.009); + --chart-4: oklch(0.491 0.27 292.581); + --chart-5: oklch(0.432 0.232 292.759); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.606 0.25 292.717); --sidebar-primary-foreground: oklch(0.969 0.016 293.756); - --sidebar-accent: oklch(0.967 0.001 286.375 / 0.6); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.606 0.25 292.717); --surface: oklch(0.985 0.006 285.885); --bg-surface: var(--surface); @@ -69,44 +45,43 @@ --glass-base: var(--bg-surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.12); - --glass-noise-opacity: 0.03; } .dark { - --background: oklch(0.141 0.005 285.823); + --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885 / 0.6); + --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); + --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.541 0.281 293.009); --primary-foreground: oklch(0.969 0.016 293.756); - --secondary: oklch(0.274 0.006 286.033 / 0.55); + --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033 / 0.55); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033 / 0.55); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.541 0.281 293.009); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885 / 0.6); + --chart-1: oklch(0.811 0.111 293.571); + --chart-2: oklch(0.606 0.25 292.717); + --chart-3: oklch(0.541 0.281 293.009); + --chart-4: oklch(0.491 0.27 292.581); + --chart-5: oklch(0.432 0.232 292.759); + --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.541 0.281 293.009); --sidebar-primary-foreground: oklch(0.969 0.016 293.756); - --sidebar-accent: oklch(0.274 0.006 286.033 / 0.55); + --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.541 0.281 293.009); - --surface: oklch(0.21 0.006 285.885); + --surface: oklch(0.205 0 0); - --bg-surface: oklch(0.21 0.006 285.885); + --bg-surface: oklch(0.205 0 0); --glass-base: var(--bg-surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.32); @@ -145,7 +120,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.11 0.012 264.4 / 0.12); - --glass-noise-opacity: 0.018; } :root.dark[data-app-theme='graphite'] { @@ -179,7 +153,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.38); - --glass-noise-opacity: 0.018; } :root[data-app-theme='ocean'] { @@ -213,7 +186,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.13 0.02 246.1 / 0.12); - --glass-noise-opacity: 0.02; } :root.dark[data-app-theme='ocean'] { @@ -247,7 +219,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.36); - --glass-noise-opacity: 0.02; } :root[data-app-theme='amber'] { @@ -281,7 +252,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.16 0.024 63.6 / 0.12); - --glass-noise-opacity: 0.022; } :root.dark[data-app-theme='amber'] { @@ -315,7 +285,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.02; } :root[data-app-theme='github'] { @@ -349,7 +318,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.12 0.008 248.2 / 0.1); - --glass-noise-opacity: 0.012; } :root.dark[data-app-theme='github'] { @@ -383,7 +351,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.012; } :root[data-app-theme='nord'] { @@ -417,7 +384,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.16 0.02 258.6 / 0.1); - --glass-noise-opacity: 0.018; } :root.dark[data-app-theme='nord'] { @@ -451,7 +417,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.33); - --glass-noise-opacity: 0.018; } :root[data-app-theme='everforest'] { @@ -485,7 +450,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.18 0.022 156.1 / 0.1); - --glass-noise-opacity: 0.018; } :root.dark[data-app-theme='everforest'] { @@ -519,7 +483,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.018; } :root[data-app-theme='rosepine'] { @@ -553,7 +516,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0.19 0.024 313.7 / 0.1); - --glass-noise-opacity: 0.016; } :root.dark[data-app-theme='rosepine'] { @@ -587,7 +549,6 @@ --glass-base: var(--surface); --glass-tint: var(--primary); --glass-shadow-color: oklch(0 0 0 / 0.34); - --glass-noise-opacity: 0.016; } /* OLED dark mode — only activates for the default application theme */ @@ -607,14 +568,16 @@ --bg-surface: oklch(0.02 0.001 285); --glass-base: oklch(0.02 0.001 285); --glass-shadow-color: oklch(0 0 0 / 0.6); - --glass-noise-opacity: 0.015; } @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); --color-background: var(--background); --color-foreground: var(--foreground); @@ -646,13 +609,14 @@ --color-sidebar-ring: var(--sidebar-ring); --color-surface: var(--surface); - --font-sans: 'Mona Sans', system-ui, sans-serif; - --font-mono: 'Mona Sans Mono', monospace; + --font-sans: 'Montserrat Variable', sans-serif; + --font-heading: 'Geist Mono Variable', monospace; + --font-mono: 'Geist Mono Variable', monospace; } @layer base { * { - @apply border-border; + @apply border-border outline-ring/50; } body { @@ -662,6 +626,12 @@ -moz-osx-font-smoothing: auto; text-rendering: optimizeLegibility; } + + html { + @apply font-sans; + font-size: 14px; + } + button { @apply cursor-pointer; } @@ -717,49 +687,12 @@ } } -body.glass-enabled { - background-color: var(--background); -} - -body.glass-enabled::before { - display: none !important; -} - -@media (prefers-reduced-motion: reduce) { - body.glass-enabled::before { - animation: none; - } -} - -body::after { - content: ''; - position: fixed; - inset: 0; - z-index: -1; - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNDAiIGhlaWdodD0iMTQwIiB2aWV3Qm94PSIwIDAgMTQwIDE0MCI+PGZpbHRlciBpZD0ibiI+PGZlVHVyYnVsZW5jZSB0eXBlPSJmcmFjdGFsTm9pc2UiIGJhc2VGcmVxdWVuY3k9IjAuOSIgbnVtT2N0YXZlcz0iNCIgc3RpdGNoVGlsZXM9InN0aXRjaCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCNuKSIgb3BhY2l0eT0iMC4wNCIvPjwvc3ZnPg=='); - background-size: 200px 200px; - opacity: var(--glass-noise-opacity); - pointer-events: none; - backface-visibility: hidden; - transform: translateZ(0); - will-change: transform; -} - -@keyframes ambient-float { - 0% { - transform: translate3d(-1.5%, -1%, 0) scale(1); - } - 100% { - transform: translate3d(1.5%, 1%, 0) scale(1.02); - } -} - @layer utilities { .bubble { background: radial-gradient( 120% 100% at 10% 0%, - color-mix(in oklch, var(--glass-tint, var(--primary)) 10%, transparent) 0%, + color-mix(in oklch, var(--glass-tint, var(--primary)) 7%, transparent) 0%, transparent 60% ), radial-gradient( @@ -768,10 +701,8 @@ body::after { transparent 60% ); background-color: color-mix(in oklch, var(--glass-base, var(--bg-surface)) 92%, transparent); - border-radius: var(--radius-xl); - box-shadow: - 0 8px 24px -8px color-mix(in oklch, var(--glass-shadow-color) 70%, transparent), - 0 1px 0 0 color-mix(in oklch, var(--glass-base, var(--bg-surface)) 30%, transparent) inset; + border-radius: var(--radius-lg); + box-shadow: 0 2px 8px -4px color-mix(in oklch, var(--glass-shadow-color) 50%, transparent); } .bubble-pill { @@ -781,15 +712,11 @@ body::after { } .bubble-shadow { - box-shadow: - 0 8px 30px -12px color-mix(in oklch, var(--glass-shadow-color) 70%, transparent), - 0 1px 0 0 color-mix(in oklch, var(--glass-base, var(--bg-surface)) 30%, transparent) inset; + box-shadow: 0 1px 2px -1px color-mix(in oklch, var(--glass-shadow-color) 60%, transparent); } .bubble-shadow-lg { - box-shadow: - 0 14px 40px -14px color-mix(in oklch, var(--glass-shadow-color) 80%, transparent), - 0 1px 0 0 color-mix(in oklch, var(--glass-base, var(--bg-surface)) 35%, transparent) inset; + box-shadow: 0 6px 20px -10px color-mix(in oklch, var(--glass-shadow-color) 60%, transparent); } .hover-lift { @@ -799,7 +726,7 @@ body::after { } .hover-lift:hover { - transform: translateY(-2px) translateZ(0); + transform: translateZ(0); } @media (prefers-reduced-motion: reduce) { @@ -818,38 +745,6 @@ body::after { border: 1px solid transparent; border-radius: var(--radius-lg); } - - /* Floating blob utility for accent backgrounds on sections */ - .blob-floating { - position: relative; - isolation: isolate; - } - .blob-floating::before { - content: ''; - position: absolute; - inset: -20%; - z-index: -1; - background: - radial-gradient( - 40% 40% at 20% 20%, - color-mix(in oklch, var(--glass-tint, var(--primary)) 20%, transparent) 0%, - transparent 60% - ), - radial-gradient(40% 50% at 80% 30%, color-mix(in oklch, var(--chart-2) 14%, transparent) 0%, transparent 60%); - filter: blur(60px) saturate(105%); - opacity: 0.22; - animation: blob-float var(--blob-speed, 18s) ease-in-out infinite alternate; - will-change: transform; - } - - @keyframes blob-float { - 0% { - transform: translate3d(-2%, 0, 0) scale(1); - } - 100% { - transform: translate3d(2%, 1%, 0) scale(1.05); - } - } } .version-collapsed { diff --git a/frontend/src/app.html b/frontend/src/app.html index 214370ae16..d5eec40825 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -14,7 +14,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index fd0ec0471f..9e7c2994b6 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,5 +1,5 @@ import type { HandleClientError } from '@sveltejs/kit'; -import { extractApiErrorMessage } from '$lib/utils/api.util'; +import { extractApiErrorMessage } from '$lib/utils/api'; export const handleError: HandleClientError = async ({ error, message, status }) => { if (error && typeof error === 'object' && 'response' in error) { diff --git a/frontend/src/lib/components/accent-color/accent-color-picker.svelte b/frontend/src/lib/components/accent-color/accent-color-picker.svelte index 5d3fbd96c4..6083677264 100644 --- a/frontend/src/lib/components/accent-color/accent-color-picker.svelte +++ b/frontend/src/lib/components/accent-color/accent-color-picker.svelte @@ -1,7 +1,7 @@ {#snippet RedeployActionButton(size: 'default' | 'icon' = 'default', showLabel = true)} - {#if disableRedeploy} - - - - {:else} - confirmAction('redeploy')} loading={uiLoading.redeploy} /> + {#if canRedeploy} + {#if disableRedeploy} + + + + {:else} + confirmAction('redeploy')} loading={uiLoading.redeploy} /> + {/if} {/if} {/snippet} {#snippet RedeployMenuItem()} - {#if disableRedeploy} - - {m.common_redeploy()} - - {:else} - confirmAction('redeploy')} disabled={uiLoading.redeploy}> - {m.common_redeploy()} - + {#if canRedeploy} + {#if disableRedeploy} + + {m.common_redeploy()} + + {:else} + confirmAction('redeploy')} disabled={uiLoading.redeploy}> + {m.common_redeploy()} + + {/if} {/if} {/snippet} @@ -856,7 +436,7 @@ {#if isLgUp}
- {#if !isRunning} + {#if !isRunning && canStart} {#if type === 'container'} {:else} - - handleDeploy()} - loading={uiLoading.start} - /> - + handleDeploy()} + loading={uiLoading.start} + /> {/if} {/if} {#if isRunning} - handleStop()} - loading={uiLoading.stop} - /> - handleRestart()} - loading={uiLoading.restart} - /> + {#if canStop} + handleStop()} + loading={uiLoading.stop} + /> + {/if} + {#if canRestart} + handleRestart()} + loading={uiLoading.restart} + /> + {/if} {/if} {#if type === 'container'} {@render RedeployActionButton(adaptiveIconOnly ? 'icon' : 'default', !adaptiveIconOnly)} - confirmAction('remove')} - loading={uiLoading.remove} - /> + {#if canRemove} + confirmAction('remove')} + loading={uiLoading.remove} + /> + {/if} {:else} {@render RedeployActionButton(adaptiveIconOnly ? 'icon' : 'default', !adaptiveIconOnly)} {#if type === 'project'} - {#if projectHasBuildDirective} - - handleProjectBuild()} - loading={projectBuilding} - /> - + {#if projectHasBuildDirective && canBuild} + handleProjectBuild()} + loading={uiLoading.build} + /> {/if} - + {#if canPull} handleProjectPull()} - loading={projectPulling} + loading={uiLoading.pull} /> - + {/if} {/if} {#if onRefresh} @@ -977,14 +524,16 @@ /> {/if} - confirmAction('remove')} - loading={uiLoading.remove} - /> + {#if canRemove} + confirmAction('remove')} + loading={uiLoading.remove} + /> + {/if} {/if}
{:else} @@ -1000,7 +549,7 @@ class="bg-popover/20 z-50 min-w-[180px] rounded-xl border p-1 shadow-lg backdrop-blur-md" > - {#if !isRunning} + {#if !isRunning && canStart} {#if type === 'container'} {m.common_start()} @@ -1030,32 +579,40 @@ {/if} {/if} - {:else} - - {type === 'project' ? m.common_down() : m.common_stop()} - - - {m.common_restart()} - + {:else if isRunning} + {#if canStop} + + {type === 'project' ? m.common_down() : m.common_stop()} + + {/if} + {#if canRestart} + + {m.common_restart()} + + {/if} {/if} {#if type === 'container'} {@render RedeployMenuItem()} - confirmAction('remove')} disabled={uiLoading.remove}> - {m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {m.common_remove()} + + {/if} {:else} {@render RedeployMenuItem()} {#if type === 'project'} - {#if projectHasBuildDirective} - + {#if projectHasBuildDirective && canBuild} + {m.build()} {/if} - - {m.images_pull()} - + {#if canPull} + + {m.images_pull()} + + {/if} {/if} {#if onRefresh} @@ -1064,154 +621,73 @@ {/if} - confirmAction('remove')} disabled={uiLoading.remove}> - {type === 'project' ? m.compose_destroy() : m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {type === 'project' ? m.compose_destroy() : m.common_remove()} + + {/if} {/if} - - {#if type === 'project'} - - - - - - - - - - - - {/if} {/if} {:else}
@@ -1227,7 +703,7 @@ class="bg-popover/20 z-50 min-w-[180px] rounded-xl border p-1 shadow-lg backdrop-blur-md" > - {#if !isRunning} + {#if !isRunning && canStart} {#if type === 'container'} {m.common_start()} @@ -1257,32 +733,40 @@ {/if} {/if} - {:else} - - {type === 'project' ? m.common_down() : m.common_stop()} - - - {m.common_restart()} - + {:else if isRunning} + {#if canStop} + + {type === 'project' ? m.common_down() : m.common_stop()} + + {/if} + {#if canRestart} + + {m.common_restart()} + + {/if} {/if} {#if type === 'container'} {@render RedeployMenuItem()} - confirmAction('remove')} disabled={uiLoading.remove}> - {m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {m.common_remove()} + + {/if} {:else} {@render RedeployMenuItem()} {#if type === 'project'} - {#if projectHasBuildDirective} - + {#if projectHasBuildDirective && canBuild} + {m.build()} {/if} - - {m.images_pull()} - + {#if canPull} + + {m.images_pull()} + + {/if} {/if} {#if onRefresh} @@ -1291,9 +775,11 @@ {/if} - confirmAction('remove')} disabled={uiLoading.remove}> - {type === 'project' ? m.compose_destroy() : m.common_remove()} - + {#if canRemove} + confirmAction('remove')} disabled={uiLoading.remove}> + {type === 'project' ? m.compose_destroy() : m.common_remove()} + + {/if} {/if} diff --git a/frontend/src/lib/components/activity/activity-cancel.ts b/frontend/src/lib/components/activity/activity-cancel.ts new file mode 100644 index 0000000000..8fcc44be13 --- /dev/null +++ b/frontend/src/lib/components/activity/activity-cancel.ts @@ -0,0 +1,34 @@ +import { openConfirmDialog } from '$lib/components/confirm-dialog'; +import { toast } from 'svelte-sonner'; +import { m } from '$lib/paraglide/messages'; +import { activityStore } from '$lib/stores/activity.store.svelte'; + +/** + * Opens a confirmation dialog and, on confirm, requests cancellation of the + * activity. Shared by the activity center row action and the detail panel so the + * confirm copy, toasts, and the already-finished (409) handling stay in one place. + */ +export function confirmCancelActivity(activityId: string) { + openConfirmDialog({ + title: m.activity_cancel_title(), + message: m.activity_cancel_message(), + confirm: { + label: m.activity_cancel_confirm(), + destructive: true, + action: async () => { + try { + await activityStore.cancelActivity(activityId); + toast.success(m.activity_cancel_success()); + } catch (error) { + if ((error as { response?: { status?: number } })?.response?.status === 409) { + toast.info(m.activity_cancel_already_finished()); + await activityStore.refresh(); + return; + } + console.error('Failed to cancel activity:', error); + toast.error(m.activity_cancel_failed()); + } + } + } + }); +} diff --git a/frontend/src/lib/components/activity/activity-center-trigger.svelte b/frontend/src/lib/components/activity/activity-center-trigger.svelte new file mode 100644 index 0000000000..36ab8c8be8 --- /dev/null +++ b/frontend/src/lib/components/activity/activity-center-trigger.svelte @@ -0,0 +1,108 @@ + + +{#if compact} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/activity/activity-center.svelte b/frontend/src/lib/components/activity/activity-center.svelte new file mode 100644 index 0000000000..b402f01d4b --- /dev/null +++ b/frontend/src/lib/components/activity/activity-center.svelte @@ -0,0 +1,191 @@ + + + +
+
+ {#if activityStore.activeCount > 0} + + {m.activity_active_count({ count: activityStore.activeCount })} + + {/if} +
+ +
+
+ {#each filters as filter (filter)} + + {/each} +
+ + + + +
+
+ +
+ {#if activityStore.environmentFailures.length > 0} +
+
+
+
+ {/if} + + {#if activityStore.loading && activityStore.activities.length === 0} +
+
+
+
+ {:else if activityStore.filteredActivities.length === 0} +
+
+
+
+ {:else} +
+ {#each activityStore.filteredActivities as activity (activity.id)} + {@const expanded = activityStore.isExpanded(activity.id)} + {@const cancelable = activity.status === 'running' || activity.status === 'queued'} +
+ activityStore.setActivityExpanded(activity.id, open)}> + + + + + + + + {#if cancelable} + + + + {/if} +
+ {/each} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/activity/activity-detail-panel.svelte b/frontend/src/lib/components/activity/activity-detail-panel.svelte new file mode 100644 index 0000000000..ab92cf7994 --- /dev/null +++ b/frontend/src/lib/components/activity/activity-detail-panel.svelte @@ -0,0 +1,244 @@ + + +
+
+
+
+
+
+
+
+
+

{activityTypeLabel(liveActivity.type)}

+ +
+

{activityTarget}

+
+
+ {#if cancelable} + + + + {/if} +
+ +
+
+ {liveActivity.step || m.activity_step_unknown()} + + {#if hasProgress} + {m.activity_progress_percent({ progress: progressValue })} + {:else} + {m.common_live()} + {/if} + +
+ +
+ +
+
+ {m.common_started()} + {formatDateTimeInternal(liveActivity.startedAt)} +
+ +
+ {m.common_finished()} + {formatDateTimeInternal(liveActivity.endedAt)} +
+ +
+ {m.activity_duration()} + {formatDurationInternal(liveActivity)} +
+ {#if sourceEnvironmentName} + +
+ {m.activity_source_environment()} + {sourceEnvironmentName} +
+ {/if} + {#if startedByName} + +
+ {m.activity_started_by_label()} + {startedByName} +
+ {/if} +
+ + {#if liveActivity.error} +
+ {liveActivity.error} +
+ {/if} +
+ +
+
+
+
+ + {m.activity_copy_output()} + +
+ +
+ {#if isDetailError && messages.length === 0} +
+ {m.activity_output_load_failed()} + +
+ {:else if isLoading && messages.length === 0} +
+
+ {:else if messages.length === 0} +
+ {m.activity_output_empty()} +
+ {:else} + {#each messages as message (message.id)} +
+ {formatDateTimeInternal(message.createdAt)} + + {message.level.toUpperCase()} + + {message.message} +
+ {/each} + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/activity/activity-labels.ts b/frontend/src/lib/components/activity/activity-labels.ts new file mode 100644 index 0000000000..0bc279341f --- /dev/null +++ b/frontend/src/lib/components/activity/activity-labels.ts @@ -0,0 +1,140 @@ +import { m } from '$lib/paraglide/messages'; +import type { ActivityFilter, ActivityStatus, ActivityType } from '$lib/types/activity.type'; +import type { IconType } from '$lib/icons'; +import { + ActivityIcon, + DownloadIcon, + HammerIcon, + RedeployIcon, + RefreshIcon, + RestartIcon, + ScanIcon, + StartIcon, + StopIcon, + TrashIcon +} from '$lib/icons'; + +export type ActivityBadgeVariant = 'red' | 'green' | 'blue' | 'gray' | 'amber' | 'purple'; + +export function activityStatusLabel(status: ActivityStatus): string { + switch (status) { + case 'queued': + return m.activity_status_queued(); + case 'running': + return m.common_running(); + case 'success': + return m.common_success(); + case 'failed': + return m.common_failed(); + case 'cancelled': + return m.activity_status_cancelled(); + } +} + +export function activityStatusVariant(status: ActivityStatus): ActivityBadgeVariant { + switch (status) { + case 'queued': + return 'amber'; + case 'running': + return 'blue'; + case 'success': + return 'green'; + case 'failed': + return 'red'; + case 'cancelled': + return 'gray'; + } +} + +export function activityTypeLabel(type: ActivityType): string { + switch (type) { + case 'image_pull': + return m.activity_type_image_pull(); + case 'image_build': + return m.activity_type_image_build(); + case 'image_update_check': + return m.activity_type_image_update_check(); + case 'project_pull': + return m.activity_type_project_pull(); + case 'project_build': + return m.activity_type_project_build(); + case 'project_deploy': + return m.activity_type_project_deploy(); + case 'project_redeploy': + return m.activity_type_project_redeploy(); + case 'project_down': + return m.activity_type_project_down(); + case 'project_restart': + return m.activity_type_project_restart(); + case 'project_destroy': + return m.activity_type_project_destroy(); + case 'container_start': + return m.activity_type_container_start(); + case 'container_stop': + return m.activity_type_container_stop(); + case 'container_restart': + return m.activity_type_container_restart(); + case 'container_redeploy': + return m.activity_type_container_redeploy(); + case 'container_delete': + return m.activity_type_container_delete(); + case 'vulnerability_scan': + return m.activity_type_vulnerability_scan(); + case 'auto_update': + return m.activity_type_auto_update(); + case 'system_prune': + return m.activity_type_system_prune(); + case 'resource_action': + return m.activity_type_resource_action(); + } +} + +export function activityTypeIcon(type: ActivityType): IconType { + switch (type) { + case 'image_pull': + case 'project_pull': + return DownloadIcon; + case 'image_build': + case 'project_build': + return HammerIcon; + case 'image_update_check': + return RefreshIcon; + case 'project_deploy': + return ActivityIcon; + case 'container_start': + return StartIcon; + case 'project_redeploy': + case 'container_redeploy': + return RedeployIcon; + case 'project_down': + case 'container_stop': + return StopIcon; + case 'project_restart': + case 'container_restart': + return RestartIcon; + case 'project_destroy': + case 'container_delete': + return TrashIcon; + case 'vulnerability_scan': + return ScanIcon; + case 'auto_update': + return RefreshIcon; + case 'system_prune': + return TrashIcon; + case 'resource_action': + return ActivityIcon; + default: + return ActivityIcon; + } +} + +export function activityFilterLabel(filter: ActivityFilter): string { + switch (filter) { + case 'running': + return m.common_running(); + case 'failed': + return m.common_failed(); + case 'completed': + return m.activity_filter_completed(); + } +} diff --git a/frontend/src/lib/components/activity/activity-list-item.svelte b/frontend/src/lib/components/activity/activity-list-item.svelte new file mode 100644 index 0000000000..cb01ec760a --- /dev/null +++ b/frontend/src/lib/components/activity/activity-list-item.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+
+
+
+
+
+ {activityTypeLabel(activity.type)} + {#if relativeTime} + · {relativeTime} + {/if} +
+
{targetName}
+
+ {#if sourceEnvironmentName} + {sourceEnvironmentName} + {/if} + {#if startedByName} + · + {m.activity_started_by({ user: startedByName })} + {/if} +
+
+ +
+ +
+
{subtitle}
+ {#if isActive && !expanded} +
+ + + {#if hasProgress} + {m.activity_progress_percent({ progress: progressValue })} + {:else} + {m.common_live()} + {/if} + +
+ {/if} +
+
+ +
+
+
diff --git a/frontend/src/lib/components/application-theme/application-theme-picker.svelte b/frontend/src/lib/components/application-theme/application-theme-picker.svelte index 0be92e830f..0042c442d2 100644 --- a/frontend/src/lib/components/application-theme/application-theme-picker.svelte +++ b/frontend/src/lib/components/application-theme/application-theme-picker.svelte @@ -5,9 +5,9 @@ import * as RadioGroup from '$lib/components/ui/radio-group/index.js'; import { mode } from 'mode-watcher'; import { m } from '$lib/paraglide/messages'; - import { APPLICATION_THEME_OPTIONS, applyApplicationTheme, resolveApplicationTheme } from '$lib/utils/application-theme-util'; + import { APPLICATION_THEME_OPTIONS, applyApplicationTheme, resolveApplicationTheme } from '$lib/utils/theme'; import type { CarouselAPI } from '$lib/components/ui/carousel/context.js'; - import type { ApplicationTheme } from '$lib/types/settings.type'; + import type { ApplicationTheme } from '$lib/types/settings'; let { selectedTheme = $bindable(), diff --git a/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte b/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte index 4a97d858a5..f7863ceefd 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte +++ b/frontend/src/lib/components/arcane-table/arcane-table-desktop-view.svelte @@ -214,7 +214,7 @@
diff --git a/frontend/src/lib/components/arcane-table/arcane-table-pagination.svelte b/frontend/src/lib/components/arcane-table/arcane-table-pagination.svelte index 0a21829eee..ce4898971e 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table-pagination.svelte +++ b/frontend/src/lib/components/arcane-table/arcane-table-pagination.svelte @@ -5,7 +5,7 @@ import { m } from '$lib/paraglide/messages'; import { ArrowRightIcon, ArrowLeftIcon, DoubleArrowRightIcon, DoubleArrowLeftIcon } from '$lib/icons'; import { TABLE_PAGE_SIZE_ALL, TABLE_PAGE_SIZE_OPTIONS } from '$lib/constants/table-pagination'; - import type { Paginated } from '$lib/types/pagination.type'; + import type { Paginated } from '$lib/types/shared'; let { items, diff --git a/frontend/src/lib/components/arcane-table/arcane-table-toolbar.svelte b/frontend/src/lib/components/arcane-table/arcane-table-toolbar.svelte index 730ff351a7..f997a53416 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table-toolbar.svelte +++ b/frontend/src/lib/components/arcane-table/arcane-table-toolbar.svelte @@ -9,7 +9,7 @@ vulnerabilitySeverityFilters, projectStatusFilters } from './data.js'; - import { debounced } from '$lib/utils/utils.js'; + import { debounced } from '$lib/utils/ws'; import { ArcaneButton } from '$lib/components/arcane-button'; import { m } from '$lib/paraglide/messages'; import type { Snippet } from 'svelte'; diff --git a/frontend/src/lib/components/arcane-table/arcane-table.svelte b/frontend/src/lib/components/arcane-table/arcane-table.svelte index 039f936dd4..f6b96f5ef5 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table.svelte +++ b/frontend/src/lib/components/arcane-table/arcane-table.svelte @@ -13,7 +13,7 @@ import DataTableToolbar from './arcane-table-toolbar.svelte'; import { renderComponent, renderSnippet } from '$lib/components/ui/data-table/render-helpers.js'; import { onMount, untrack } from 'svelte'; - import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; + import type { Paginated, SearchPaginationSortRequest } from '$lib/types/shared'; import type { Snippet } from 'svelte'; import type { ColumnSpec } from './arcane-table.types.svelte'; import TableCheckbox from './arcane-table-checkbox.svelte'; @@ -33,7 +33,7 @@ type BulkAction } from './arcane-table.types.svelte'; import type { Component } from 'svelte'; - import { extractPersistedPreferences, filterMapsEqual, toFilterMap } from './arcane-table.utils'; + import { extractPersistedPreferences, filterMapsEqual, fromFilterMap, toFilterMap } from './arcane-table.utils'; import ArcaneTablePagination from './arcane-table-pagination.svelte'; import ArcaneTableHeader from './arcane-table-header.svelte'; import ArcaneTableCell from './arcane-table-cell.svelte'; @@ -607,6 +607,19 @@ } }); + // Reflect externally-set requestOptions.filters (e.g. a clickable stat card applying + // a filter) back into the facet UI so the displayed filters match the active query. + // Only mutates local columnFilters — never triggers onRefresh — so it can't loop with + // the forward onColumnFiltersChange path. + $effect(() => { + const incoming = requestOptions?.filters; + const currentMap = untrack(() => toFilterMap(columnFilters)); + if (filterMapsEqual(incoming, currentMap)) return; + untrack(() => { + columnFilters = fromFilterMap(incoming); + }); + }); + // Track last persisted settings to prevent infinite loops let lastPersistedSettings: string | null = null; let persistTimeout: ReturnType | null = null; diff --git a/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts b/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts index fa5606fa66..1c8c12801c 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts +++ b/frontend/src/lib/components/arcane-table/arcane-table.types.svelte.ts @@ -130,7 +130,7 @@ export function buildMobileVisibility(fields: FieldSpec[], persisted?: string[]) export type BulkAction = { id: string; label: string; - action: 'base' | 'start' | 'stop' | 'restart' | 'remove' | 'deploy' | 'redeploy' | 'up' | 'down'; + action: 'base' | 'start' | 'stop' | 'restart' | 'remove' | 'deploy' | 'redeploy' | 'up' | 'down' | 'prune'; onClick: (ids: string[]) => void; disabled?: boolean; disabledReason?: string; diff --git a/frontend/src/lib/components/arcane-table/arcane-table.utils.ts b/frontend/src/lib/components/arcane-table/arcane-table.utils.ts index 1a77b52640..c7ee3fd663 100644 --- a/frontend/src/lib/components/arcane-table/arcane-table.utils.ts +++ b/frontend/src/lib/components/arcane-table/arcane-table.utils.ts @@ -1,5 +1,5 @@ import type { ColumnFiltersState } from '@tanstack/table-core'; -import type { FilterMap } from '$lib/types/pagination.type'; +import type { FilterMap } from '$lib/types/shared'; import type { CompactTablePrefs } from './arcane-table.types.svelte'; import { decodeFilters, decodeSort } from './arcane-table.types.svelte'; @@ -34,6 +34,23 @@ export function toFilterMap(filters: ColumnFiltersState): FilterMap { return out; } +/** + * Inverse of {@link toFilterMap}: rebuilds tanstack `ColumnFiltersState` from a + * request `FilterMap`. Used to reflect externally-set `requestOptions.filters` + * (e.g. a clickable stat card) back into the faceted-filter UI. Values are kept + * as-is so they match the facet option `value` types (array, boolean, string). + */ +export function fromFilterMap(map?: FilterMap): ColumnFiltersState { + if (!map) return []; + const out: ColumnFiltersState = []; + for (const [id, value] of Object.entries(map)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value) && value.length === 0) continue; + out.push({ id, value }); + } + return out; +} + export function filterMapsEqual(a?: FilterMap, b?: FilterMap): boolean { const keysA = Object.keys(a ?? {}); const keysB = Object.keys(b ?? {}); diff --git a/frontend/src/lib/components/badges/port-badge.svelte b/frontend/src/lib/components/badges/port-badge.svelte index 1f6c71eea1..40d1c135bc 100644 --- a/frontend/src/lib/components/badges/port-badge.svelte +++ b/frontend/src/lib/components/badges/port-badge.svelte @@ -1,9 +1,9 @@ + + diff --git a/frontend/src/lib/components/dialogs/create-container-dialog.svelte b/frontend/src/lib/components/dialogs/create-container-dialog.svelte index 46ac3f5554..9aba01beef 100644 --- a/frontend/src/lib/components/dialogs/create-container-dialog.svelte +++ b/frontend/src/lib/components/dialogs/create-container-dialog.svelte @@ -8,9 +8,9 @@ import { Label } from '$lib/components/ui/label/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js'; - import type { ContainerCreateRequest } from '$lib/types/container.type'; + import type { ContainerCreateRequest } from '$lib/types/docker'; import { z } from 'zod/v4'; - import { createForm, preventDefault } from '$lib/utils/form.utils'; + import { createForm, preventDefault } from '$lib/utils/settings'; import SelectWithLabel from '../form/select-with-label.svelte'; import { m } from '$lib/paraglide/messages'; import { diff --git a/frontend/src/lib/components/dialogs/create-service-dialog.svelte b/frontend/src/lib/components/dialogs/create-service-dialog.svelte index 03c5c48630..881796fe30 100644 --- a/frontend/src/lib/components/dialogs/create-service-dialog.svelte +++ b/frontend/src/lib/components/dialogs/create-service-dialog.svelte @@ -4,12 +4,12 @@ import { Input } from '$lib/components/ui/input/index.js'; import * as Select from '$lib/components/ui/select/index.js'; import { z } from 'zod/v4'; - import { createForm, preventDefault } from '$lib/utils/form.utils'; + import { createForm, preventDefault } from '$lib/utils/settings'; import { m } from '$lib/paraglide/messages'; import { AddIcon, TrashIcon } from '$lib/icons'; import * as Accordion from '$lib/components/ui/accordion/index.js'; import FormInput from '$lib/components/form/form-input.svelte'; - import type { SwarmServiceCreateSpec } from '$lib/types/swarm.type'; + import type { SwarmServiceCreateSpec } from '$lib/types/swarm'; type CreateServiceDialogProps = { open: boolean; diff --git a/frontend/src/lib/components/dialogs/docker-info-dialog.svelte b/frontend/src/lib/components/dialogs/docker-info-dialog.svelte index 1aab502792..1342c4d249 100644 --- a/frontend/src/lib/components/dialogs/docker-info-dialog.svelte +++ b/frontend/src/lib/components/dialogs/docker-info-dialog.svelte @@ -4,10 +4,10 @@ import { Badge } from '$lib/components/ui/badge'; import { CopyButton } from '$lib/components/ui/copy-button'; import { Spinner } from '$lib/components/ui/spinner'; - import type { DockerInfo } from '$lib/types/docker-info.type'; + import type { DockerInfo } from '$lib/types/docker'; import { m } from '$lib/paraglide/messages'; - import bytes from '$lib/utils/bytes'; - import { formatDateTimeShort } from '$lib/utils/locale.util'; + import { bytes } from '$lib/utils/formatting'; + import { formatDateTimeShort } from '$lib/utils/formatting'; interface Props { open: boolean; @@ -26,6 +26,17 @@ if (!timeStr) return '-'; return formatDateTimeShort(timeStr) || timeStr; } + + function shortCommit(commit: { ID?: string } | undefined | null): string { + const id = commit?.ID; + if (!id) return '-'; + return id.length > 12 ? id.slice(0, 12) : id; + } + + function swarmActive(info: DockerInfo): boolean { + const state = (info.Swarm as { LocalNodeState?: string } | undefined)?.LocalNodeState; + return !!state && state !== 'inactive'; + } {#snippet dialogBody(info: DockerInfo)} -
-
- {@render statsSection(info)} - {@render resourcesSection(info)} -
+
+ {#if info.Warnings && info.Warnings.length > 0} + {@render warningsCard(info.Warnings)} + {/if} -
+ {@render statsSection(info)} + {@render resourcesSection(info)} + +
{@render systemSection(info)} {@render versionSection(info)} {@render configurationSection(info)} -
- -
- {@render networkSection(info)} + {@render capabilitiesSection(info)} + {#if info.DriverStatus && info.DriverStatus.length > 0} + {@render storageDetailsSection(info)} + {/if} {@render securitySection(info)} {@render pluginsSection(info)} + {#if swarmActive(info)} + {@render swarmSection(info)} + {/if} + {#if info.Labels && info.Labels.length > 0} + {@render labelsSection(info)} + {/if} + {@render networkSection(info)}
{/snippet} +{#snippet warningsCard(warnings: string[])} +
+

+ {m.docker_info_warnings_section()} +

+
    + {#each warnings as warning, i (i)} +
  • {warning}
  • + {/each} +
+
+{/snippet} + {#snippet statsSection(info: DockerInfo)}

@@ -99,20 +132,51 @@

{/snippet} +{#snippet resourcesSection(info: DockerInfo)} +
+

+ {m.docker_info_resources_section()} +

+
+
+
{m.common_cpus()}
+
+ {info.NCPU ?? 0} + cores +
+
+
+
{m.docker_info_memory_label()}
+ {info.MemTotal ? bytes.format(info.MemTotal) : '-'} +
+
+
{m.docker_info_goroutines()}
+ {info.NGoroutines ?? 0} +
+
+
{m.docker_info_file_descriptors()}
+ {info.NFd ?? 0} +
+
+
+{/snippet} + {#snippet systemSection(info: DockerInfo)}

{m.docker_info_system_section()}

-
+
{@render infoRow(m.common_name(), info.Name)} {@render infoRow(m.common_id(), info.ID, true)} {@render infoRow(m.docker_info_os_label(), info.OperatingSystem)} + {@render infoRow(m.docker_info_os_version_label(), info.OSVersion)} {@render infoRow(m.docker_info_os_type_label(), info.OSType)} {@render infoRow(m.common_architecture(), info.Architecture)} {@render infoRow(m.docker_info_kernel_version_label(), info.KernelVersion)} {@render infoRow(m.docker_info_system_time(), formatTime(info.SystemTime), false)} {@render infoRow(m.docker_info_root_dir(), info.DockerRootDir, true)} + {@render infoRow(m.docker_info_index_server_label(), info.IndexServerAddress, true)}
{/snippet} @@ -122,13 +186,13 @@

{m.docker_info_version_section()}

-
+
{@render infoRow(m.docker_info_server_version_label(), info.ServerVersion)} {@render infoRow(m.docker_info_api_version_label(), info.apiVersion)} {@render infoRow(m.docker_info_go_version_label(), info.goVersion)} -
- {m.docker_info_git_commit_label()} -
+
+ {m.docker_info_git_commit_label()} +
{info.gitCommit?.slice(0, 8) ?? '-'} {#if info.gitCommit} @@ -137,35 +201,12 @@
{@render infoRow(m.docker_info_build_time_label(), formatTime(info.buildTime), false)} {@render infoRow(m.docker_info_experimental(), info.ExperimentalBuild ? m.common_yes() : m.common_no(), false)} -
-
-{/snippet} - -{#snippet resourcesSection(info: DockerInfo)} -
-

- {m.docker_info_resources_section()} -

-
-
-
{m.common_cpus()}
-
- {info.NCPU ?? 0} - cores -
-
-
-
{m.docker_info_memory_label()}
- {info.MemTotal ? bytes.format(info.MemTotal) : '-'} -
-
-
{m.docker_info_goroutines()}
- {info.NGoroutines ?? 0} -
-
-
{m.docker_info_file_descriptors()}
- {info.NFd ?? 0} -
+ {@render infoRow(m.docker_info_containerd_commit_label(), shortCommit(info.ContainerdCommit), true)} + {@render infoRow(m.docker_info_runc_commit_label(), shortCommit(info.RuncCommit), true)} + {@render infoRow(m.docker_info_init_commit_label(), shortCommit(info.InitCommit), true)} + {#if info.ProductLicense} + {@render infoRow(m.docker_info_product_license_label(), info.ProductLicense, false)} + {/if}
{/snippet} @@ -175,7 +216,7 @@

{m.common_configuration()}

-
+
{@render infoRow(m.docker_info_storage_driver_label(), info.Driver)} {@render infoRow(m.docker_info_logging_driver_label(), info.LoggingDriver)} {@render infoRow(m.docker_info_cgroup_driver_label(), info.CgroupDriver)} @@ -183,6 +224,41 @@ {@render infoRow(m.docker_info_isolation(), info.Isolation)} {@render infoRow(m.docker_info_init_binary(), info.InitBinary)} {@render infoRow(m.docker_info_default_runtime(), info.DefaultRuntime)} + {@render infoRow(m.docker_info_debug_label(), info.Debug ? m.common_yes() : m.common_no(), false)} + {@render infoRow(m.docker_info_live_restore_label(), info.LiveRestoreEnabled ? m.common_yes() : m.common_no(), false)} + {@render infoRow(m.docker_info_event_listeners_label(), info.NEventsListener ?? 0, false)} +
+
+{/snippet} + +{#snippet capabilitiesSection(info: DockerInfo)} +
+

+ {m.docker_info_capabilities_section()} +

+
+ {@render capRow(m.docker_info_memory_limit_label(), info.MemoryLimit)} + {@render capRow(m.docker_info_swap_limit_label(), info.SwapLimit)} + {@render capRow(m.docker_info_kernel_memory_tcp_label(), info.KernelMemoryTCP)} + {@render capRow(m.docker_info_cpu_cfs_period_label(), info.CpuCfsPeriod)} + {@render capRow(m.docker_info_cpu_cfs_quota_label(), info.CpuCfsQuota)} + {@render capRow(m.docker_info_cpu_shares_label(), info.CPUShares)} + {@render capRow(m.docker_info_cpu_set_label(), info.CPUSet)} + {@render capRow(m.docker_info_pids_limit_label(), info.PidsLimit)} + {@render capRow(m.docker_info_oom_kill_disable_label(), info.OomKillDisable)} +
+
+{/snippet} + +{#snippet storageDetailsSection(info: DockerInfo)} +
+

+ {m.docker_info_storage_details_section()} +

+
+ {#each info.DriverStatus ?? [] as entry, i (i)} + {@render infoRow(entry[0] ?? '', entry[1], false)} + {/each}
{/snippet} @@ -192,12 +268,33 @@

{m.resource_networks_cap()} & {m.docker_info_proxy_label()}

-
+
{@render infoRow(m.docker_info_ipv4_forwarding(), info.IPv4Forwarding ? m.common_enabled() : m.common_disabled(), false)} {@render infoRow(m.docker_info_http_proxy(), info.HttpProxy)} {@render infoRow(m.docker_info_https_proxy(), info.HttpsProxy)} {@render infoRow(m.docker_info_no_proxy(), info.NoProxy)} - {@render infoRow(m.docker_info_bridge_ip(), info.DefaultAddressPools?.[0]?.Base)} + {#if info.DefaultAddressPools && info.DefaultAddressPools.length > 0} +
+
{m.docker_info_address_pools_label()}
+
+ {#each info.DefaultAddressPools as pool, i (i)} + {pool.Base}/{pool.Size} + {/each} +
+
+ {/if} +
+
+{/snippet} + +{#snippet securitySection(info: DockerInfo)} +
+

+ {m.security_title()} & {m.docker_info_runtimes()} +

+
+ {@render tagGroup(m.docker_info_security_options(), info.SecurityOptions)} + {@render tagGroup(m.docker_info_runtimes(), Object.keys(info.Runtimes ?? {}))}
{/snippet} @@ -207,66 +304,47 @@

{m.docker_info_plugins_section()}

-
-
-
{m.resource_volumes_cap()}
-
- {#each info.Plugins?.Volume ?? [] as plugin} - {plugin} - {:else} - - - {/each} -
-
-
-
{m.resource_networks_cap()}
-
- {#each info.Plugins?.Network ?? [] as plugin} - {plugin} - {:else} - - - {/each} -
-
-
-
{m.docker_info_logs_plugin()}
-
- {#each info.Plugins?.Log ?? [] as plugin} - {plugin} - {:else} - - - {/each} -
-
+
+ {@render tagGroup(m.resource_volumes_cap(), info.Plugins?.Volume)} + {@render tagGroup(m.resource_networks_cap(), info.Plugins?.Network)} + {@render tagGroup(m.docker_info_logs_plugin(), info.Plugins?.Log)} + {@render tagGroup(m.docker_info_authorization_plugin(), info.Plugins?.Authorization)}
{/snippet} -{#snippet securitySection(info: DockerInfo)} +{#snippet swarmSection(info: DockerInfo)} + {@const swarm = info.Swarm as { + LocalNodeState?: string; + ControlAvailable?: boolean; + NodeID?: string; + Managers?: number; + Nodes?: number; + }}

- {m.security_title()} & {m.docker_info_runtimes()} + {m.docker_info_swarm_section()}

-
-
-
{m.docker_info_security_options()}
-
- {#each info.SecurityOptions ?? [] as opt} - {opt} - {:else} - - - {/each} -
-
-
-
{m.docker_info_runtimes()}
-
- {#each Object.keys(info.Runtimes ?? {}) as runtime} - {runtime} - {:else} - - - {/each} -
+
+ {@render infoRow(m.docker_info_swarm_state_label(), swarm.LocalNodeState, false)} + {@render infoRow(m.docker_info_swarm_manager_label(), swarm.ControlAvailable ? m.common_yes() : m.common_no(), false)} + {@render infoRow(m.docker_info_swarm_node_id_label(), swarm.NodeID, true)} + {@render infoRow(m.docker_info_swarm_managers_label(), swarm.Managers ?? 0, false)} + {@render infoRow(m.docker_info_swarm_nodes_label(), swarm.Nodes ?? 0, false)} +
+
+{/snippet} + +{#snippet labelsSection(info: DockerInfo)} +
+

+ {m.docker_info_labels_section()} +

+
+
+ {#each info.Labels ?? [] as label, i (i)} + {label} + {/each}
@@ -303,11 +381,33 @@
{/snippet} -{#snippet infoRow(label: string, value: string | undefined | null, mono: boolean = true)} +{#snippet tagGroup(label: string, items: string[] | undefined)} +
+
{label}
+
+ {#each items ?? [] as item, i (i)} + {item} + {:else} + - + {/each} +
+
+{/snippet} + +{#snippet capRow(label: string, value: boolean | undefined)} +
+ {label} + + {value ? m.common_yes() : m.common_no()} + +
+{/snippet} + +{#snippet infoRow(label: string, value: string | number | undefined | null, mono: boolean = true)}
{label} - {value || '-'} + {value === undefined || value === null || value === '' ? '-' : value}
{/snippet} diff --git a/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte b/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte index cf672ff3e9..a249e0e6e5 100644 --- a/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte +++ b/frontend/src/lib/components/dialogs/environment-switcher-dialog.svelte @@ -6,24 +6,24 @@ import { environmentStore } from '$lib/stores/environment.store.svelte'; import { environmentManagementService } from '$lib/services/env-mgmt-service'; import { queryKeys } from '$lib/query/query-keys'; - import type { Environment } from '$lib/types/environment.type'; + import type { Environment } from '$lib/types/environment'; import { goto } from '$app/navigation'; import { toast } from 'svelte-sonner'; import { m } from '$lib/paraglide/messages'; import { cn } from '$lib/utils'; import settingsStore from '$lib/stores/config-store'; - import { debounced } from '$lib/utils/utils'; - import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; + import { debounced } from '$lib/utils/ws'; + import type { SearchPaginationSortRequest } from '$lib/types/shared'; import { tick } from 'svelte'; import { EnvironmentsIcon, RemoteEnvironmentIcon, AddIcon, SearchIcon, CloseIcon, SettingsIcon } from '$lib/icons'; import { useQueryClient } from '@tanstack/svelte-query'; + import IfPermitted from '$lib/components/if-permitted.svelte'; type Props = { open: boolean; - isAdmin?: boolean; }; - let { open = $bindable(false), isAdmin = false }: Props = $props(); + let { open = $bindable(false) }: Props = $props(); const queryClient = useQueryClient(); let searchQuery = $state(''); @@ -356,7 +356,7 @@ {#snippet footer()}
- {#if isAdmin} + - {:else} -
- {/if} + {#snippet fallback()} +
+ {/snippet} +
{/snippet} diff --git a/frontend/src/lib/components/dialogs/event-details-dialog.svelte b/frontend/src/lib/components/dialogs/event-details-dialog.svelte index 09fed333fd..e723e36b2e 100644 --- a/frontend/src/lib/components/dialogs/event-details-dialog.svelte +++ b/frontend/src/lib/components/dialogs/event-details-dialog.svelte @@ -3,11 +3,11 @@ import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import { Badge } from '$lib/components/ui/badge'; import { CopyButton } from '$lib/components/ui/copy-button'; - import type { Event } from '$lib/types/event.type'; + import type { Event } from '$lib/types/shared'; import { m } from '$lib/paraglide/messages'; import { environmentStore, LOCAL_DOCKER_ENVIRONMENT_ID } from '$lib/stores/environment.store.svelte'; import { AlertIcon, InfoIcon, EnvironmentsIcon, UserIcon, ClockIcon } from '$lib/icons'; - import { formatDateTime } from '$lib/utils/locale.util'; + import { formatDateTime } from '$lib/utils/formatting'; type Severity = 'success' | 'warning' | 'error' | 'info'; diff --git a/frontend/src/lib/components/dialogs/file-browser-dialog.svelte b/frontend/src/lib/components/dialogs/file-browser-dialog.svelte index 8d50f084c4..cbb9b4d6e4 100644 --- a/frontend/src/lib/components/dialogs/file-browser-dialog.svelte +++ b/frontend/src/lib/components/dialogs/file-browser-dialog.svelte @@ -5,22 +5,59 @@ import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { gitRepositoryService } from '$lib/services/git-repository-service'; import { queryKeys } from '$lib/query/query-keys'; - import type { FileTreeNode } from '$lib/types/gitops.type'; + import type { FileTreeNode } from '$lib/types/automation'; import { FolderOpenIcon, FileTextIcon, ArrowRightIcon } from '$lib/icons'; import { m } from '$lib/paraglide/messages'; import { createQuery } from '@tanstack/svelte-query'; + import type { Snippet } from 'svelte'; type FileBrowserDialogProps = { open: boolean; repositoryId: string; branch: string; onSelect: (filePath: string) => void; + // title/description override the dialog heading. Defaults are generic so the + // component carries no domain assumption (compose vs script vs anything else). + title?: string; + description?: string; + // rootPath scopes browsing to a subdirectory of the repo. Navigation cannot + // escape this root, the breadcrumb hides everything above it, and the + // onSelect callback receives a path relative to this root. Use this when + // the selectable file must live inside a specific subtree (e.g. a script + // must live inside the sync directory so the deploy-time runner can find + // it). Default empty = full repo. + rootPath?: string; + // fileFilter decides which files are selectable. Directories are + // always navigable. Default accepts everything; callers narrow it. + fileFilter?: (file: FileTreeNode) => boolean; + // Optional UI decorations rendered per file (badge) and beneath the + // list (hint). Callers compose these to add domain-specific affordances + // (e.g. a "COMPOSE" badge + an explanatory hint) without the dialog + // knowing anything about the domain. + fileBadge?: Snippet<[FileTreeNode]>; + footerHint?: Snippet; }; - let { open = $bindable(false), repositoryId, branch, onSelect }: FileBrowserDialogProps = $props(); + let { + open = $bindable(false), + repositoryId, + branch, + onSelect, + title = m.git_sync_browse_files_title(), + description = m.git_sync_browse_files_description(), + rootPath = '', + fileFilter = () => true, + fileBadge, + footerHint + }: FileBrowserDialogProps = $props(); - let currentPath = $state(''); - let pathSegments = $derived(currentPath.split('/').filter(Boolean)); + // Internal navigation is stored as a path relative to the dialog's root, so the + // query key and breadcrumb stay consistent the moment the dialog opens — there's + // no race between "set currentPath" and "query enables when open=true". + let userPath = $state(''); + const normalizedRoot = $derived(rootPath.replace(/^\/+|\/+$/g, '')); + const currentPath = $derived(joinPath(normalizedRoot, userPath)); + const pathSegments = $derived(userPath.split('/').filter(Boolean)); const fileTreeQuery = createQuery<{ files: FileTreeNode[] }>(() => ({ queryKey: queryKeys.gitRepositories.files(repositoryId, branch, currentPath), queryFn: () => gitRepositoryService.browseFiles(repositoryId, branch, currentPath), @@ -29,35 +66,40 @@ })); const files = $derived(fileTreeQuery.data?.files ?? []); const loading = $derived(fileTreeQuery.isPending || fileTreeQuery.isFetching); + const atRoot = $derived(userPath === ''); - function loadFiles(path: string = '') { - currentPath = path; + function joinPath(a: string, b: string): string { + if (!a) return b; + if (!b) return a; + return `${a}/${b}`; + } + + function pathRelativeToRoot(absolutePath: string): string { + if (!normalizedRoot) return absolutePath; + if (absolutePath === normalizedRoot) return ''; + if (absolutePath.startsWith(normalizedRoot + '/')) { + return absolutePath.slice(normalizedRoot.length + 1); + } + return absolutePath; } function handleFileClick(file: FileTreeNode) { if (file.type === 'directory') { - loadFiles(file.path); - } else { - // Only allow selecting compose files - if (file.name.endsWith('.yml') || file.name.endsWith('.yaml')) { - onSelect(file.path); - open = false; - } + userPath = pathRelativeToRoot(file.path); + return; + } + if (fileFilter(file)) { + onSelect(pathRelativeToRoot(file.path)); + open = false; } } function goToPath(index: number) { - const newPath = pathSegments.slice(0, index + 1).join('/'); - loadFiles(newPath); + userPath = pathSegments.slice(0, index + 1).join('/'); } function goBack() { - const segments = pathSegments.slice(0, -1); - loadFiles(segments.join('/')); - } - - function isComposeFile(fileName: string): boolean { - return fileName.endsWith('.yml') || fileName.endsWith('.yaml'); + userPath = pathSegments.slice(0, -1).join('/'); } @@ -65,19 +107,20 @@ bind:open onOpenChange={(isOpen) => { if (isOpen && repositoryId && branch) { - currentPath = ''; + userPath = ''; } }} - title={m.git_sync_browse_files_title()} - description={m.git_sync_browse_files_description()} + {title} + {description} contentClass="max-w-2xl" >
- +
- {#each pathSegments as segment, index (`${index}-${segment}`)} @@ -97,7 +140,7 @@
{m.git_sync_browse_no_files()}
{:else}
- {#if currentPath !== ''} + {#if !atRoot} {/each}
{/if} -

- {m.git_sync_browse_hint()} -

+ {@render footerHint?.()}
{#snippet footer()} diff --git a/frontend/src/lib/components/dialogs/gitops-import-dialog.svelte b/frontend/src/lib/components/dialogs/gitops-import-dialog.svelte index 98fd1ce8ae..64625771c1 100644 --- a/frontend/src/lib/components/dialogs/gitops-import-dialog.svelte +++ b/frontend/src/lib/components/dialogs/gitops-import-dialog.svelte @@ -4,7 +4,7 @@ import { Spinner } from '$lib/components/ui/spinner/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js'; - import type { ImportGitOpsSyncRequest } from '$lib/types/gitops.type'; + import type { ImportGitOpsSyncRequest } from '$lib/types/automation'; import { m } from '$lib/paraglide/messages'; type GitOpsImportFormProps = { diff --git a/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte b/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte index d269576b60..39fd634742 100644 --- a/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte +++ b/frontend/src/lib/components/dialogs/gitops-sync-dialog.svelte @@ -10,31 +10,65 @@ import { Switch } from '$lib/components/ui/switch/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import FileBrowserDialog from '$lib/components/dialogs/file-browser-dialog.svelte'; - import type { GitOpsSync, GitOpsSyncCreateDto, GitOpsSyncUpdateDto, GitRepository, BranchInfo } from '$lib/types/gitops.type'; + import type { + FileTreeNode, + GitOpsSync, + GitOpsSyncCreateDto, + GitOpsSyncUpdateDto, + GitRepository, + BranchInfo + } from '$lib/types/automation'; import { gitRepositoryService } from '$lib/services/git-repository-service'; import { settingsService } from '$lib/services/settings-service'; + import { hasPermission } from '$lib/utils/auth'; import { z } from 'zod/v4'; - import { createForm, preventDefault } from '$lib/utils/form.utils'; + import { createForm, preventDefault } from '$lib/utils/settings'; import { queryKeys } from '$lib/query/query-keys'; import { m } from '$lib/paraglide/messages'; - import { ArrowRightIcon, FolderOpenIcon, InfoIcon } from '$lib/icons'; + import { ArrowRightIcon, CodeIcon, FolderOpenIcon, InfoIcon } from '$lib/icons'; import * as Alert from '$lib/components/ui/alert'; import { createQuery } from '@tanstack/svelte-query'; type GitOpsSyncFormProps = { open: boolean; syncToEdit: GitOpsSync | null; + environmentId: string; targetType?: string; onSubmit: (detail: { sync: GitOpsSyncCreateDto | GitOpsSyncUpdateDto; isEditMode: boolean }) => void; isLoading: boolean; }; - let { open = $bindable(false), syncToEdit = $bindable(), targetType, onSubmit, isLoading }: GitOpsSyncFormProps = $props(); + let { + open = $bindable(false), + syncToEdit = $bindable(), + environmentId, + targetType, + onSubmit, + isLoading + }: GitOpsSyncFormProps = $props(); type GitOpsSyncTargetType = 'project' | 'swarm_stack'; let isEditMode = $derived(!!syncToEdit); let showFileBrowser = $state(false); + let fileBrowserTarget = $state<'compose' | 'preDeployScript'>('compose'); + + // Pre-deploy lifecycle hooks run arbitrary containers (with host mounts, env, + // and network access) on every sync, so configuring them is gated behind the + // dedicated gitops:lifecycle permission rather than the broader gitops:create + // / gitops:update. Users without it see a read-only summary instead of the + // editable section and never submit its fields; the backend enforces the same + // rule as defense-in-depth. + let canManageLifecycle = $derived(hasPermission('gitops:lifecycle', environmentId)); + + // Whether the sync being edited already has a hook configured. Used to flag + // its presence to users who can't manage it and to lock the directory-sync + // toggle so they can't accidentally invalidate the hook. + let hookConfigured = $derived(!!syncToEdit?.preDeployScriptPath?.trim()); + let lockSyncDirectory = $derived(!canManageLifecycle && hookConfigured); + + const composeFileFilter = (file: FileTreeNode) => + file.type === 'file' && (file.name.endsWith('.yml') || file.name.endsWith('.yaml')); let selectedTargetType = $state('project'); const targetTypeOptions = [ @@ -56,7 +90,13 @@ maxSyncTotalSizeMb: z.coerce.number().int().nonnegative(), maxSyncBinarySizeMb: z.coerce.number().int().nonnegative(), autoSync: z.boolean().default(true), - syncInterval: z.number().min(1).default(5) + syncInterval: z.number().min(1).default(5), + preDeployScriptPath: z.string().default(''), + preDeployRunnerImage: z.string().default(''), + preDeployTimeoutSec: z.coerce.number().int().positive().default(60), + preDeployNetworkMode: z.string().default('none'), + preDeployEnv: z.string().default(''), + preDeployExtraMounts: z.string().default('') }); const bytesPerMegabyte = 1024 * 1024; @@ -94,7 +134,13 @@ ? bytesToMegabytesInternal(syncToEdit.maxSyncBinarySize, 0) : (settingsQuery.data?.gitSyncMaxBinarySizeMb ?? 0), autoSync: open && syncToEdit ? (syncToEdit.autoSync ?? true) : true, - syncInterval: open && syncToEdit ? (syncToEdit.syncInterval ?? 5) : 5 + syncInterval: open && syncToEdit ? (syncToEdit.syncInterval ?? 5) : 5, + preDeployScriptPath: open && syncToEdit ? (syncToEdit.preDeployScriptPath ?? '') : '', + preDeployRunnerImage: open && syncToEdit ? (syncToEdit.preDeployRunnerImage ?? '') : '', + preDeployTimeoutSec: open && syncToEdit ? (syncToEdit.preDeployTimeoutSec ?? 60) : 60, + preDeployNetworkMode: open && syncToEdit ? (syncToEdit.preDeployNetworkMode ?? 'none') : 'none', + preDeployEnv: open && syncToEdit ? (syncToEdit.preDeployEnv ?? '') : '', + preDeployExtraMounts: open && syncToEdit ? (syncToEdit.preDeployExtraMounts ?? '') : '' }); let { inputs, ...form } = $derived(createForm(formSchema, formData)); @@ -166,6 +212,18 @@ syncInterval: data.syncInterval }; + // Only global admins may configure the pre-deploy lifecycle hook. Non-admins + // never send these fields, so an existing hook is left untouched on update + // and the backend's admin gate is never tripped on create. + if (canManageLifecycle) { + payload.preDeployScriptPath = data.preDeployScriptPath.trim(); + payload.preDeployRunnerImage = data.preDeployRunnerImage.trim(); + payload.preDeployTimeoutSec = data.preDeployTimeoutSec; + payload.preDeployNetworkMode = data.preDeployNetworkMode.trim(); + payload.preDeployEnv = data.preDeployEnv.trim(); + payload.preDeployExtraMounts = data.preDeployExtraMounts.trim(); + } + onSubmit({ sync: payload, isEditMode }); } @@ -285,7 +343,10 @@ type="button" variant="outline" size="icon" - onclick={() => (showFileBrowser = true)} + onclick={() => { + fileBrowserTarget = 'compose'; + showFileBrowser = true; + }} disabled={!selectedRepository?.value || !$inputs.branch.value} title={m.git_sync_browse_files_title()} > @@ -298,10 +359,13 @@
- +

{m.git_sync_sync_files_description()}

+ {#if lockSyncDirectory} +

{m.git_sync_sync_files_locked_hint()}

+ {/if} {#if $inputs.syncDirectory.error}

{$inputs.syncDirectory.error}

{/if} @@ -380,6 +444,128 @@ + {#if canManageLifecycle} + + + + {m.git_sync_pre_deploy_title()} + + + + +
+ + + + {m.git_sync_pre_deploy_acknowledgement()} + + + +

{m.git_sync_pre_deploy_description()}

+ +
+ +
+
+ +
+ +
+

{m.git_sync_pre_deploy_script_path_help()}

+ {#if $inputs.preDeployScriptPath.error} +

{$inputs.preDeployScriptPath.error}

+ {/if} +
+ + + +
+ + +
+ + + + +
+
+
+ {:else} +
+ + {m.git_sync_pre_deploy_title()} + {m.git_sync_pre_deploy_managed_hint()} + + + {#if hookConfigured} + + {m.git_sync_pre_deploy_status_configured()} + {:else} + {m.git_sync_pre_deploy_status_none()} + {/if} + +
+ {/if} + @@ -412,11 +598,38 @@ {/snippet} +{#snippet composeBadge(file: FileTreeNode)} + {#if composeFileFilter(file)} + + {m.git_sync_browse_compose_label()} + + {/if} +{/snippet} + +{#snippet composeFooterHint()} +

{m.git_sync_browse_hint()}

+{/snippet} + { - $inputs.composePath.value = path; + if (fileBrowserTarget === 'preDeployScript') { + $inputs.preDeployScriptPath.value = path; + } else { + $inputs.composePath.value = path; + } }} /> diff --git a/frontend/src/lib/components/dialogs/prune-confirmation-dialog-content.svelte b/frontend/src/lib/components/dialogs/prune-confirmation-dialog-content.svelte index 36a3ea754c..287be51ca3 100644 --- a/frontend/src/lib/components/dialogs/prune-confirmation-dialog-content.svelte +++ b/frontend/src/lib/components/dialogs/prune-confirmation-dialog-content.svelte @@ -2,8 +2,8 @@ import { ArcaneButton } from '$lib/components/arcane-button/index.js'; import PruneModeCard from '$lib/components/prune/prune-mode-card.svelte'; import { m } from '$lib/paraglide/messages'; - import type { SystemPruneRequest } from '$lib/types/prune.type'; - import type { Settings } from '$lib/types/settings.type'; + import type { SystemPruneRequest } from '$lib/types/automation'; + import type { Settings } from '$lib/types/settings'; interface Props { defaults?: Settings | null; diff --git a/frontend/src/lib/components/dialogs/prune-confirmation-dialog.svelte b/frontend/src/lib/components/dialogs/prune-confirmation-dialog.svelte index 6245825974..05e7afaf26 100644 --- a/frontend/src/lib/components/dialogs/prune-confirmation-dialog.svelte +++ b/frontend/src/lib/components/dialogs/prune-confirmation-dialog.svelte @@ -2,8 +2,8 @@ import PruneConfirmationDialogContent from '$lib/components/dialogs/prune-confirmation-dialog-content.svelte'; import { ResponsiveDialog } from '$lib/components/ui/responsive-dialog/index.js'; import { m } from '$lib/paraglide/messages'; - import type { SystemPruneRequest } from '$lib/types/prune.type'; - import type { Settings } from '$lib/types/settings.type'; + import type { SystemPruneRequest } from '$lib/types/automation'; + import type { Settings } from '$lib/types/settings'; interface Props { open: boolean; diff --git a/frontend/src/lib/components/dialogs/rbac-migration-banner.svelte b/frontend/src/lib/components/dialogs/rbac-migration-banner.svelte new file mode 100644 index 0000000000..1e263d4365 --- /dev/null +++ b/frontend/src/lib/components/dialogs/rbac-migration-banner.svelte @@ -0,0 +1,71 @@ + + +{#if visible} + +
+ {m.rbac_migration_banner_title()} + {m.rbac_migration_banner_body()} +
+ +
+{/if} diff --git a/frontend/src/lib/components/dialogs/template-selection-dialog.svelte b/frontend/src/lib/components/dialogs/template-selection-dialog.svelte index 0848f43c18..45987fbcd7 100644 --- a/frontend/src/lib/components/dialogs/template-selection-dialog.svelte +++ b/frontend/src/lib/components/dialogs/template-selection-dialog.svelte @@ -4,7 +4,7 @@ import { Card } from '$lib/components/ui/card/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; - import type { Template } from '$lib/types/template.type'; + import type { Template } from '$lib/types/swarm'; import { Label } from '$lib/components/ui/label/index.js'; import * as Select from '$lib/components/ui/select/index.js'; import * as Collapsible from '$lib/components/ui/collapsible/index.js'; diff --git a/frontend/src/lib/components/dialogs/update-center-dialog.svelte b/frontend/src/lib/components/dialogs/update-center-dialog.svelte index 3298f32403..776c74b645 100644 --- a/frontend/src/lib/components/dialogs/update-center-dialog.svelte +++ b/frontend/src/lib/components/dialogs/update-center-dialog.svelte @@ -9,7 +9,7 @@ import systemUpgradeService from '$lib/services/api/system-upgrade-service'; import { cn } from '$lib/utils'; import { ExternalLinkIcon, SuccessIcon } from '$lib/icons'; - import type { AppVersionInformation } from '$lib/types/application-configuration'; + import type { AppVersionInformation } from '$lib/types/settings'; import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify'; diff --git a/frontend/src/lib/components/dialogs/version-info-dialog.svelte b/frontend/src/lib/components/dialogs/version-info-dialog.svelte index 93b8c678b6..ee032173d0 100644 --- a/frontend/src/lib/components/dialogs/version-info-dialog.svelte +++ b/frontend/src/lib/components/dialogs/version-info-dialog.svelte @@ -1,11 +1,11 @@ @@ -30,11 +30,19 @@ import * as Alert from '$lib/components/ui/alert'; import { Label } from '$lib/components/ui/label'; import { toast } from 'svelte-sonner'; - import bytes from '$lib/utils/bytes'; + import { bytes } from '$lib/utils/formatting'; import { format } from 'date-fns'; + import { environmentStore } from '$lib/stores/environment.store.svelte'; + import { hasPermission } from '$lib/utils/auth'; + import IfPermitted from '$lib/components/if-permitted.svelte'; + import { activityToastOptions, extractActivityId } from '$lib/utils/activity-toast'; let { provider, rootLabel, persistKey }: { provider: FileProvider; rootLabel?: string; persistKey?: string } = $props(); + const currentEnvId = $derived(environmentStore.selected?.id || '0'); + const canDeleteVolume = $derived(hasPermission('volumes:delete', currentEnvId)); + const canBackupVolume = $derived(hasPermission('volumes:backup', currentEnvId)); + let currentPath = $state('/'); let files = $state([]); let loading = $state(true); @@ -113,8 +121,8 @@ if (!restoreTarget || !provider.restoreFromBackup || !selectedBackupId) return; restoringFile = true; try { - await provider.restoreFromBackup(selectedBackupId, restoreTarget.path); - toast.success(m.volumes_backup_file_restore_success()); + const result = await provider.restoreFromBackup(selectedBackupId, restoreTarget.path); + toast.success(m.volumes_backup_file_restore_success(), activityToastOptions(extractActivityId(result))); showRestoreFile = false; // Refresh the file list to show the restored file await loadFiles(currentPath); @@ -167,22 +175,24 @@
- (showCreateFolder = true)} - icon={MoveToFolderIcon} - customLabel={m.volumes_browser_new_folder()} - /> - (showUpload = true)} - icon={UploadIcon} - customLabel={m.volumes_browser_upload_files()} - /> + + (showCreateFolder = true)} + icon={MoveToFolderIcon} + customLabel={m.volumes_browser_new_folder()} + /> + (showUpload = true)} + icon={UploadIcon} + customLabel={m.volumes_browser_upload_files()} + /> +
@@ -201,10 +211,10 @@ {persistKey} onNavigate={handleNavigate} onRefresh={() => loadFiles(currentPath)} - onDelete={(file) => provider.delete(file.path)} + onDelete={canDeleteVolume ? (file) => provider.delete(file.path) : undefined} onDownload={(file) => provider.download(file.path)} onPreview={(file) => (previewFile = file)} - onRestoreFromBackup={canRestoreFromBackup ? openRestoreFileDialog : undefined} + onRestoreFromBackup={canRestoreFromBackup && canBackupVolume ? openRestoreFileDialog : undefined} /> {/if}
@@ -315,17 +325,19 @@ selectedBackupId = ''; }} /> - + {#if canBackupVolume} + + {/if} {/snippet} diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index c39782205f..a7ca58f9c8 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -7,7 +7,7 @@ import { Label } from '$lib/components/ui/label'; import type { Snippet } from 'svelte'; import type { HTMLAttributes } from 'svelte/elements'; - import type { FormInput } from '$lib/utils/form.utils'; + import type { FormInput } from '$lib/utils/settings'; let { input = $bindable(), @@ -19,6 +19,7 @@ disabled = false, type = 'text', rows = 3, + inputClass = '', children, autocomplete = 'off', ...restProps @@ -32,6 +33,9 @@ disabled?: boolean; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date' | 'switch' | 'textarea'; rows?: number; + // Forwarded to the inner Input/Textarea so callers can apply + // content-specific styling (e.g. font-mono for code-like values). + inputClass?: string; children?: Snippet; autocomplete?: HTMLInputElement['autocomplete']; } = $props(); @@ -53,11 +57,11 @@ {#if type === 'switch'} {:else if type === 'textarea'} -